FastMM documentation
FastMM C++ API 0.6.0
Public headers, tier 1 and tier 2
Loading...
Searching...
No Matches
dead_mans_switch.hpp
Go to the documentation of this file.
1#pragma once
2// Venue-side dead man's switch: the venue cancels the account's resting orders when the
3// connector stops talking to it, whatever the reason. `cancel_on_order_channel_loss` is not
4// this — that one is the connector issuing a REST cancel-all, and it needs a live process and a
5// working network. A SIGKILL, an OOM kill, a kernel panic or a host that loses power leaves the
6// quotes resting. Only the venue can clear them then.
7//
8// The venues offer two shapes, and only the first needs anything here:
9//
10// countdown The connector arms a timer ("cancel everything unless you hear from me within
11// N ms") and keeps pushing it out. Binance USDⓈ-M countdownCancelAll. The window
12// is the exposure after a hard kill, and every refresh costs rate limit, so the
13// refresh runs at a fraction of the window rather than at its edge.
14// CountdownSwitch below is the timer for it, CountdownDriver the rules around it.
15// on-disconnect The venue watches its own socket and cancels when it dies. Deribit
16// private/enable_cancel_on_disconnect, Bybit's Disconnect-Cancel-All. Armed once
17// per connection or once per account; there is nothing to refresh, so these are
18// wired in the connectors directly.
19//
20// Binance Spot has neither, as of the 2026-09 API docs: neither rest-api.md nor
21// web-socket-api.md has a countdown, a session auto-cancel or a cancel-on-disconnect, and the
22// FIX session's "countdown" is a maintenance logout that leaves orders alone. The only
23// venue-side primitive is the manual openOrders.cancelAll. Spot quotes survive a hard kill.
24#include <algorithm>
25#include <cstdint>
26
27namespace fastmm::venues {
28
29// Refresh clock for a countdown dead man's switch. Holds no venue detail: the connector asks
30// `due()` from its housekeeping timer, sends its own request, reports `attempted()`, and reports
31// `armed()` when the venue answers.
32//
33// The two clocks are separate on purpose. `attempted` paces retries, so a refresh that never
34// reaches the venue is tried again next period instead of every tick. `armed` is what the window
35// is measured from, so a request that goes out and comes back an error does not pretend the
36// countdown was pushed out. If this process is what broke, neither clock moves, the venue's
37// countdown runs out and it cancels — which is the point.
38class CountdownSwitch {
39 public:
40 CountdownSwitch() = default;
41 // `window_ms` is what the venue is told; 0 disables the switch. `refresh_numerator` /
42 // `refresh_denominator` is the fraction of the window at which the refresh goes out
43 // (1/3 by default: two refreshes may be lost before the venue acts).
44 explicit CountdownSwitch(std::int64_t window_ms,
45 std::int64_t refresh_numerator = 1,
46 std::int64_t refresh_denominator = 3) noexcept
47 : window_ms_(std::max<std::int64_t>(0, window_ms)),
48 period_ns_(window_ms_ <= 0
49 ? 0
50 : std::max<std::int64_t>(
52 window_ms_ * 1'000'000 * std::max<std::int64_t>(1, refresh_numerator) /
53 std::max<std::int64_t>(1, refresh_denominator))) {}
54
55 // A refresh sent more often than this is wasted rate limit whatever the window is.
56 static constexpr std::int64_t kMinPeriodNs = 500'000'000;
57
58 [[nodiscard]] bool enabled() const noexcept { return window_ms_ > 0; }
59 [[nodiscard]] std::int64_t window_ms() const noexcept { return window_ms_; }
60 [[nodiscard]] std::int64_t refresh_period_ns() const noexcept { return period_ns_; }
62 // True when a refresh has to go out now. Also true for the first arm, and true again a refresh
63 // period after an attempt that the venue did not confirm, so a failure is retried.
64 [[nodiscard]] bool due(std::int64_t now_ns) const noexcept {
65 return enabled() && (last_attempt_ns_ == 0 || now_ns - last_attempt_ns_ >= period_ns_);
66 }
67 // A refresh was written to the venue. Only stops due() from firing again immediately; it does
68 // not mean the countdown is running.
69 void attempted(std::int64_t now_ns) noexcept { last_attempt_ns_ = now_ns == 0 ? 1 : now_ns; }
70 // The venue accepted the refresh: the countdown is running from here.
71 void armed(std::int64_t now_ns) noexcept { last_armed_ns_ = now_ns == 0 ? 1 : now_ns; }
72
73 // The window has run out since the last refresh the venue confirmed: it has cancelled our
74 // orders, or is about to. This process is still alive (it is asking), so something between it
75 // and the venue is broken. It must not simply put back the quotes the venue just pulled —
76 // that is a quoter racing a kill switch it cannot see. A switch that was up and has lapsed is
77 // a kill. One that was never up is not: the connector is loud about those failures instead,
78 // because a venue that refuses the request outright must not stop the session dead.
79 [[nodiscard]] bool expired(std::int64_t now_ns) const noexcept {
80 return enabled() && last_armed_ns_ != 0 && now_ns - last_armed_ns_ >= window_ms_ * 1'000'000;
81 }
82 [[nodiscard]] bool ever_armed() const noexcept { return last_armed_ns_ != 0; }
83
84 // The session ended, or the kill already fired: the next due() starts from scratch.
85 void disarm() noexcept {
86 last_attempt_ns_ = 0;
87 last_armed_ns_ = 0;
88 }
89
90 private:
91 std::int64_t window_ms_ = 0;
92 std::int64_t period_ns_ = 0;
93 std::int64_t last_attempt_ns_ = 0;
94 std::int64_t last_armed_ns_ = 0;
95};
96
97// What a connector does with a countdown switch, the same for every venue that has one (Binance
98// USDⓈ-M countdownCancelAll, OKX cancel-all-after). The connector sends the requests; this decides
99// when, and what a lapse means.
100//
101// Refresh A refresh is a round of `parts` requests (one per symbol on USDⓈ-M, where the
102// countdown is per symbol; one on OKX, where it is per account). The switch counts as
103// pushed out only when every part of the round is confirmed, from the time the round
104// went out: the venue's timer starts when it receives the request, which is after
105// that, so the lapse is never declared later than the venue acts. A symbol whose
106// refresh fails is a symbol whose countdown still runs from the last round.
107// Lapse The window ran out since the last confirmed round: the venue has cancelled our
108// orders, or is about to. `poll` reports it once; the connector kills the venue (the
109// engine must not put back quotes the venue just pulled). No refresh goes out after
110// that: whatever the local cancels do, the venue's own timer is left to run out, so
111// the account ends flat by the venue's hand even when the lapse was only lost replies.
112// A reconnect (reset) starts afresh.
113// Stop At a requested shutdown the connector cancels its own orders, and the countdown is
114// stopped rather than left against an account nobody quotes. Needed once any refresh
115// went out, confirmed or not: a request whose reply was lost may have armed it.
116class CountdownDriver {
117 public:
118 enum class Step : std::uint8_t { None, Refresh, Lapsed };
119
120 CountdownDriver() = default;
121 explicit CountdownDriver(std::int64_t window_ms,
122 std::int64_t refresh_numerator = 1,
123 std::int64_t refresh_denominator = 3) noexcept
124 : clock_(window_ms, refresh_numerator, refresh_denominator) {}
125
126 [[nodiscard]] bool enabled() const noexcept { return clock_.enabled(); }
127 [[nodiscard]] std::int64_t window_ms() const noexcept { return clock_.window_ms(); }
128 [[nodiscard]] bool lapsed() const noexcept { return lapsed_; }
129 [[nodiscard]] bool ever_armed() const noexcept { return clock_.ever_armed(); }
130 [[nodiscard]] const CountdownSwitch& clock() const noexcept { return clock_; }
132 // From the housekeeping timer. Lapsed is returned once per session.
133 [[nodiscard]] Step poll(std::int64_t now_ns) noexcept {
134 if (!clock_.enabled() || lapsed_) return Step::None;
135 if (clock_.expired(now_ns)) {
136 lapsed_ = true;
137 return Step::Lapsed;
138 }
139 return clock_.due(now_ns) ? Step::Refresh : Step::None;
140 }
141
142 // A refresh round of `parts` requests starts now; the token goes with each part's reply. Call
143 // went_out() if at least one part was sent: a round nothing of which was sent is tried again on
144 // the next tick.
145 [[nodiscard]] std::uint32_t begin_round(std::int64_t now_ns, std::uint32_t parts) noexcept {
146 ++round_;
147 round_ns_ = now_ns == 0 ? 1 : now_ns;
148 round_parts_ = parts;
149 round_confirmed_ = 0;
150 return round_;
151 }
152 void went_out() noexcept {
153 clock_.attempted(round_ns_);
154 ever_sent_ = true;
155 }
156 // The venue confirmed one part of round `round`. A reply to an older round, or one after the
157 // lapse, changes nothing.
158 void confirmed(std::uint32_t round) noexcept {
159 if (round != round_ || lapsed_ || round_confirmed_ >= round_parts_) return;
160 if (++round_confirmed_ == round_parts_) clock_.armed(round_ns_);
161 }
162
163 // Whether the shutdown has to stop the venue's countdown.
164 [[nodiscard]] bool needs_stop() const noexcept { return clock_.enabled() && ever_sent_; }
165
166 // The session ended (after the stop): the next connect starts from scratch.
167 void reset() noexcept {
168 clock_.disarm();
169 lapsed_ = false;
170 ever_sent_ = false;
171 ++round_;
172 round_parts_ = 0;
173 round_confirmed_ = 0;
174 }
175
176 private:
177 CountdownSwitch clock_;
178 bool lapsed_ = false;
179 bool ever_sent_ = false;
180 std::uint32_t round_ = 0;
181 std::uint32_t round_parts_ = 0;
182 std::uint32_t round_confirmed_ = 0;
183 std::int64_t round_ns_ = 0;
184};
185
186} // namespace fastmm::venues
void confirmed(std::uint32_t round) noexcept
The venue confirmed one part of round round.
Definition dead_mans_switch.hpp:160
bool ever_armed() const noexcept
Definition dead_mans_switch.hpp:131
Step
Definition dead_mans_switch.hpp:120
void went_out() noexcept
Definition dead_mans_switch.hpp:154
std::uint32_t begin_round(std::int64_t now_ns, std::uint32_t parts) noexcept
A refresh round of parts requests starts now; the token goes with each part's reply.
Definition dead_mans_switch.hpp:147
std::int64_t window_ms() const noexcept
Definition dead_mans_switch.hpp:129
void reset() noexcept
The session ended (after the stop): the next connect starts from scratch.
Definition dead_mans_switch.hpp:169
const CountdownSwitch & clock() const noexcept
Definition dead_mans_switch.hpp:132
bool enabled() const noexcept
Definition dead_mans_switch.hpp:128
bool lapsed() const noexcept
Definition dead_mans_switch.hpp:130
bool needs_stop() const noexcept
Whether the shutdown has to stop the venue's countdown.
Definition dead_mans_switch.hpp:166
Step poll(std::int64_t now_ns) noexcept
From the housekeeping timer. Lapsed is returned once per session.
Definition dead_mans_switch.hpp:135
Refresh clock for a countdown dead man's switch.
Definition dead_mans_switch.hpp:40
bool enabled() const noexcept
Definition dead_mans_switch.hpp:60
void attempted(std::int64_t now_ns) noexcept
A refresh was written to the venue.
Definition dead_mans_switch.hpp:71
void disarm() noexcept
The session ended, or the kill already fired: the next due() starts from scratch.
Definition dead_mans_switch.hpp:87
static constexpr std::int64_t kMinPeriodNs
A refresh sent more often than this is wasted rate limit whatever the window is.
Definition dead_mans_switch.hpp:58
bool ever_armed() const noexcept
Definition dead_mans_switch.hpp:84
void armed(std::int64_t now_ns) noexcept
The venue accepted the refresh: the countdown is running from here.
Definition dead_mans_switch.hpp:73
std::int64_t window_ms() const noexcept
Definition dead_mans_switch.hpp:61
bool due(std::int64_t now_ns) const noexcept
True when a refresh has to go out now.
Definition dead_mans_switch.hpp:66
bool expired(std::int64_t now_ns) const noexcept
The window has run out since the last refresh the venue confirmed: it has cancelled our orders,...
Definition dead_mans_switch.hpp:81
std::int64_t refresh_period_ns() const noexcept
Definition dead_mans_switch.hpp:62
Definition venue.hpp:38
constexpr Fixed< T > max(Fixed< T > a, Fixed< T > b) noexcept
Definition fixed_point.hpp:418