Skip to main content

brokapi_conformance/
lib.rs

1//! Shared behaviour scenarios that every broker adapter must pass against its own mock.
2//!
3//! This is the test layer of the connector library. Each adapter (`brokapi-bcs`, `brokapi-tbank`,
4//! `brokapi-finam`, ...) implements the capability traits of [`brokapi_core`], and each runs the same
5//! scenarios from this crate against its own in-process broker imitation (see `brokapi-mocks`).
6//! So all brokers behave the same through the traits: the same refusal is the same
7//! [`brokapi_core::Error`], the same stream gives the same envelope. The scenarios use only the
8//! traits, never an adapter's own types.
9//!
10//! A scenario is an `async fn` that takes the connector (and sometimes the event receiver). It
11//! returns `()` and **panics** when the adapter breaks the contract. The panic message starts with
12//! `[conformance:<scenario name>]` and gives the reason, so the failing scenario and the cause are
13//! visible in the test output. A scenario that passes leaves the connector usable, with its test
14//! orders cancelled.
15//!
16//! What a scenario proves is that the adapter agrees with the mock. The mocks are written from the
17//! broker documentation. They do not prove how the live broker behaves.
18//!
19//! The mock behind the adapter must:
20//! - serve an order book (both sides non-empty) for the instrument passed in;
21//! - stream at least one order book update after subscribe;
22//! - acknowledge a limit order and cancel it;
23//! - refuse a limit order whose price is [`REJECT_PRICE`] (for [`rejected_order_does_not_block_the_instrument`]);
24//! - serve a portfolio snapshot and push at least one portfolio update after subscribe;
25//! - for [`stop_order_far_from_market`]: accept a stop order, list it while it waits, cancel it;
26//! - for [`market_data_history`]: serve the tape, candles for the range, trading status and a schedule;
27//! - for [`market_data_streams`]: push a market trade, a quote and a candle after each subscribe;
28//! - for [`order_state_events`]: push an order event when an order is placed and when it is cancelled;
29//! - for [`active_orders_lifecycle`] / [`replace_order`]: keep placed orders in `active_orders`;
30//! - for [`executions`]: execute an order on request (the adapter's closure) and keep the trade history.
31//!
32//! Trading scenarios need the command gate open, so call [`reconcile`] first on a user data
33//! connector. Until the first reconcile every command fails with
34//! [`brokapi_core::Error::NotReconciled`].
35//!
36//! The book is at <https://brokapi.ru/docs/>.
37//!
38//! # Examples
39//!
40//! The usual adapter test: start the mock, start the connector on it, run the scenarios, shut down.
41//! Here the adapter is the BCS connector.
42//!
43//! ```
44//! # use brokapi_core::Price;
45//! use brokapi_bcs::limits::default_limits;
46//! use brokapi_bcs::{UserDataConfig, UserDataConnector};
47//! use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
48//! use brokapi_runtime::BrokerLimiter;
49//!
50//! # #[tokio::main(flavor = "current_thread")]
51//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
52//! # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
53//! # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
54//! # let (tx, _rx) = tokio::sync::mpsc::channel(256);
55//! # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
56//! # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
57//! // `ud` is a connected user data connector, `sber` the test instrument.
58//! brokapi_conformance::reconcile(&ud).await;
59//! brokapi_conformance::place_and_cancel(&ud, &sber, Price::from(1)).await;
60//! # ud.shutdown(ShutdownMode::Immediate).await;
61//! # Ok(())
62//! # }
63//! ```
64
65#![warn(missing_docs)]
66
67use std::future::Future;
68use std::time::Duration;
69
70use brokapi_core::{
71    ClientOrderId, Decimal, Envelope, Error, Event, ExecutionSource, InstrumentId, InstrumentQuery,
72    InstrumentSource, MarketDataSource, OrderBook, OrderBookSource, OrderExecutor, OrderState,
73    OrderType, PlaceOrderRequest, PlaceStopOrderRequest, PortfolioSource, Price, Qty,
74    ReplaceOrderRequest, Side, StopExpiry, StopOrderExecutor, StopOrderKind, StopOrderRef,
75    StopOrderState, SubscribeOpts, SubscriptionId, TimeRange, Timeframe, TradingAvailability,
76    UnixNanos,
77};
78use tokio::sync::mpsc;
79use tokio::time::timeout;
80
81/// A limit order at exactly this price is refused by every mock (the broker says no).
82///
83/// Scenarios use it to get a definite refusal on demand. The refusal must be a clear answer from
84/// the broker side, not a timeout, so the library reports it as an error other than
85/// [`brokapi_core::Error::Unknown`]. The value is a price the mocks agree on. It has no meaning at a live
86/// broker, never send it there.
87///
88/// # Examples
89///
90/// A refused order is an error, and it leaves the connector able to take the next order:
91///
92/// ```
93/// # use brokapi_bcs::limits::default_limits;
94/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
95/// # use brokapi_core::{
96/// #     ClientOrderId, Error, InstrumentId, InstrumentKey, NativeId, OrderExecutor, OrderType,
97/// #     PlaceOrderRequest, Price, Qty, ShutdownMode, Side,
98/// # };
99/// # use brokapi_runtime::BrokerLimiter;
100/// use brokapi_conformance::REJECT_PRICE;
101/// # #[tokio::main(flavor = "current_thread")]
102/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
103/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
104/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
105/// # let (tx, _rx) = tokio::sync::mpsc::channel(256);
106/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
107/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
108/// brokapi_conformance::reconcile(&ud).await; // opens the command gate
109/// let refused = ud
110///     .place(PlaceOrderRequest {
111///         cid: ClientOrderId::new_v4(),
112///         instrument: sber,
113///         side: Side::Buy,
114///         order_type: OrderType::Limit,
115///         qty: Qty::from(10),
116///         price: Some(REJECT_PRICE),
117///     })
118///     .await;
119/// assert!(refused.is_err());
120/// assert!(!matches!(refused, Err(Error::Unknown)), "a refusal is a clear answer");
121/// # ud.shutdown(ShutdownMode::Immediate).await;
122/// # Ok(())
123/// # }
124/// ```
125pub const REJECT_PRICE: Price = Price::new(Decimal::from_parts(1337, 0, 0, false, 2));
126
127/// How long a scenario waits for a pushed event, or for one command, before it fails.
128///
129/// [`next_matching`] gives up after this time in total. Scenarios that must not hang (for example
130/// [`rejected_order_does_not_block_the_instrument`]) wrap each step in the same limit. A mock that is
131/// slower than this makes the scenario fail with a "no expected event" or "hung" message.
132///
133/// # Examples
134///
135/// ```
136/// use std::time::Duration;
137/// assert_eq!(brokapi_conformance::EVENT_TIMEOUT, Duration::from_secs(8));
138/// ```
139pub const EVENT_TIMEOUT: Duration = Duration::from_secs(8);
140
141fn fail(scenario: &str, why: impl std::fmt::Display) -> ! {
142    panic!("[conformance:{scenario}] {why}")
143}
144
145/// One lot of the instruments the mocks serve, in units: 10. The scenarios order whole lots, because an
146/// order of units that is not a whole number of lots is refused (`invalid_qty`) by the connectors that
147/// know the lot. A connector that does not look the lot up itself (BCS, Finam) is told the lot of the
148/// test instrument with `price_grid().set_lot(..)` in the test.
149pub const LOT: i64 = 10;
150
151/// `n` lots of the test instruments, in units.
152pub fn lots(n: i64) -> Qty {
153    Qty::from(n * LOT)
154}
155
156/// Reads envelopes from `rx` until `pred` accepts one, and returns that one.
157///
158/// Envelopes that do not match are read and thrown away, so they are not seen by later calls. Use a
159/// precise `pred` (the event kind and, when several subscriptions run, the subscription id).
160/// `scenario` is only the name shown in the failure message.
161///
162/// The whole wait is limited to [`EVENT_TIMEOUT`], not each envelope.
163///
164/// # Panics
165///
166/// With `[conformance:<scenario>] ...` when the channel is closed before a matching envelope arrives
167/// (`event channel closed before the expected event`), and when none arrives within [`EVENT_TIMEOUT`]
168/// (`no expected event within 8s`).
169///
170/// # Examples
171///
172/// ```
173/// use brokapi_conformance::next_matching;
174/// use brokapi_core::{Envelope, Event, Timestamps, UnixNanos};
175/// use tokio::sync::mpsc;
176///
177/// # #[tokio::main(flavor = "current_thread")]
178/// # async fn main() {
179/// let (tx, mut rx) = mpsc::channel(4);
180/// let ts = Timestamps::new(UnixNanos::ZERO, std::time::Instant::now());
181/// tx.send(Envelope::new("demo".into(), ts, Event::Balances(vec![]))).await.unwrap();
182/// tx.send(Envelope::new("demo".into(), ts, Event::Positions(vec![]))).await.unwrap();
183///
184/// // The balances envelope is skipped, the positions envelope is returned.
185/// let env = next_matching("my_scenario", &mut rx, |e| matches!(e.payload, Event::Positions(_))).await;
186/// assert!(matches!(env.payload, Event::Positions(_)));
187/// # }
188/// ```
189///
190/// When nothing matching can arrive, the scenario fails with its name in the message:
191///
192/// ```should_panic
193/// use brokapi_conformance::next_matching;
194/// use brokapi_core::{Envelope, Event};
195/// use tokio::sync::mpsc;
196///
197/// # #[tokio::main(flavor = "current_thread")]
198/// # async fn main() {
199/// let (tx, mut rx) = mpsc::channel::<Envelope>(1);
200/// drop(tx); // the connector went away
201/// // panics: [conformance:my_scenario] event channel closed before the expected event
202/// next_matching("my_scenario", &mut rx, |_| true).await;
203/// # }
204/// ```
205pub async fn next_matching(
206    scenario: &str,
207    rx: &mut mpsc::Receiver<Envelope>,
208    pred: impl Fn(&Envelope) -> bool,
209) -> Envelope {
210    let got = timeout(EVENT_TIMEOUT, async {
211        while let Some(env) = rx.recv().await {
212            if pred(&env) {
213                return Some(env);
214            }
215        }
216        None
217    })
218    .await;
219    match got {
220        Ok(Some(env)) => env,
221        Ok(None) => fail(scenario, "event channel closed before the expected event"),
222        Err(_) => fail(
223            scenario,
224            format!("no expected event within {EVENT_TIMEOUT:?}"),
225        ),
226    }
227}
228
229fn book_sides(
230    book: &OrderBook,
231) -> (
232    &InstrumentId,
233    &[brokapi_core::BookLevel],
234    &[brokapi_core::BookLevel],
235) {
236    match book {
237        OrderBook::Snapshot {
238            instrument,
239            bids,
240            asks,
241            ..
242        }
243        | OrderBook::Increment {
244            instrument,
245            bids,
246            asks,
247            ..
248        } => (instrument, bids, asks),
249    }
250}
251
252/// REST order book: the right instrument, the asked depth, sorted best-first, not crossed.
253///
254/// Checks on `md.order_book(id, depth)`:
255/// - the call succeeds and returns [`brokapi_core::OrderBook::Snapshot`] (not an increment);
256/// - the book is for the requested instrument (same [`brokapi_core::InstrumentKey`]);
257/// - both sides have at least one level;
258/// - neither side has more than `depth` levels;
259/// - bids are sorted from the highest price, asks from the lowest (equal neighbours pass);
260/// - the best bid is strictly below the best ask.
261///
262/// The mock must serve both sides for `id` and respect `depth`.
263///
264/// # Panics
265///
266/// With `[conformance:order_book_snapshot] <reason>` when a check fails or `order_book` returns an error.
267///
268/// # Examples
269///
270/// ```
271/// # use brokapi_bcs::limits::default_limits;
272/// # use brokapi_bcs::{MarketDataConfig, MarketDataConnector};
273/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
274/// # use brokapi_runtime::BrokerLimiter;
275/// # #[tokio::main(flavor = "current_thread")]
276/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
277/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
278/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
279/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
280/// # let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "bcs-md"), BrokerLimiter::new(&default_limits()), tx).await?;
281/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
282/// brokapi_conformance::order_book_snapshot(&md, &sber, 20).await;
283/// # md.shutdown(ShutdownMode::Immediate).await;
284/// # Ok(())
285/// # }
286/// ```
287///
288/// A scenario that fails stops the test with its name and the reason. Depth 0 is something no book can
289/// satisfy, so this one panics:
290///
291/// ```should_panic(expected = "[conformance:order_book_snapshot] depth 0 exceeded")
292/// # use brokapi_bcs::limits::default_limits;
293/// # use brokapi_bcs::{MarketDataConfig, MarketDataConnector};
294/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
295/// # use brokapi_runtime::BrokerLimiter;
296/// # #[tokio::main(flavor = "current_thread")]
297/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
298/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
299/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
300/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
301/// # let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "bcs-md"), BrokerLimiter::new(&default_limits()), tx).await?;
302/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
303/// // panics with a message that starts with [conformance:order_book_snapshot]
304/// brokapi_conformance::order_book_snapshot(&md, &sber, 0).await;
305/// # md.shutdown(ShutdownMode::Immediate).await;
306/// # Ok(())
307/// # }
308/// ```
309pub async fn order_book_snapshot<M: OrderBookSource + ?Sized>(
310    md: &M,
311    id: &InstrumentId,
312    depth: u32,
313) {
314    const S: &str = "order_book_snapshot";
315    let book = md
316        .order_book(id, depth)
317        .await
318        .unwrap_or_else(|e| fail(S, format!("order_book failed: {e:?}")));
319    let OrderBook::Snapshot { .. } = &book else {
320        fail(S, "REST order_book must return a Snapshot");
321    };
322    let (inst, bids, asks) = book_sides(&book);
323    if inst.key != id.key {
324        fail(
325            S,
326            format!(
327                "instrument {} != requested {}",
328                inst.key.display(),
329                id.key.display()
330            ),
331        );
332    }
333    if bids.is_empty() || asks.is_empty() {
334        fail(S, "mock must serve both sides");
335    }
336    if bids.len() > depth as usize || asks.len() > depth as usize {
337        fail(
338            S,
339            format!(
340                "depth {depth} exceeded: {} bids, {} asks",
341                bids.len(),
342                asks.len()
343            ),
344        );
345    }
346    if bids.windows(2).any(|w| w[0].price < w[1].price) {
347        fail(S, "bids must be sorted from best (highest) price");
348    }
349    if asks.windows(2).any(|w| w[0].price > w[1].price) {
350        fail(S, "asks must be sorted from best (lowest) price");
351    }
352    if bids[0].price >= asks[0].price {
353        fail(S, "book is crossed: best bid >= best ask");
354    }
355}
356
357/// Order book stream: subscribe, then an order book envelope arrives tagged with our source and subscription id.
358///
359/// Subscribes with [`brokapi_core::SubscribeOpts::conflate`] and waits (up to [`EVENT_TIMEOUT`]) for the
360/// first [`brokapi_core::Event::OrderBook`] envelope on `rx`. Checks that:
361/// - `env.source` equals `source` (the label the connector was configured with);
362/// - `env.sub` is the id that `subscribe_order_book` returned;
363/// - the book is for the requested instrument.
364///
365/// Returns the subscription id and leaves the subscription open, so the caller can pass it to
366/// [`unsubscribe`]. `rx` must be the receiver of the connector under test. Envelopes read before the
367/// match are discarded. If an older order book subscription is still running on the same connector, its
368/// book may arrive first and fail the sub check, so use a connector with no other book streams.
369///
370/// The mock must push at least one order book update after subscribe.
371///
372/// # Panics
373///
374/// With `[conformance:order_book_stream] <reason>` when the subscribe fails, when no order book arrives
375/// (see [`next_matching`]), or when the source, subscription id or instrument is wrong.
376///
377/// # Examples
378///
379/// ```
380/// # use brokapi_bcs::limits::default_limits;
381/// # use brokapi_bcs::{MarketDataConfig, MarketDataConnector};
382/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
383/// # use brokapi_runtime::BrokerLimiter;
384/// # #[tokio::main(flavor = "current_thread")]
385/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
386/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
387/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
388/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
389/// # let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "bcs-md"), BrokerLimiter::new(&default_limits()), tx).await?;
390/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
391/// let sub = brokapi_conformance::order_book_stream(&md, &mut rx, "bcs-md", &sber).await;
392/// brokapi_conformance::unsubscribe(&md, sub).await;
393/// # md.shutdown(ShutdownMode::Immediate).await;
394/// # Ok(())
395/// # }
396/// ```
397pub async fn order_book_stream<M: OrderBookSource + ?Sized>(
398    md: &M,
399    rx: &mut mpsc::Receiver<Envelope>,
400    source: &str,
401    id: &InstrumentId,
402) -> SubscriptionId {
403    const S: &str = "order_book_stream";
404    let sub = md
405        .subscribe_order_book(id, SubscribeOpts::conflate())
406        .await
407        .unwrap_or_else(|e| fail(S, format!("subscribe_order_book failed: {e:?}")));
408    let env = next_matching(S, rx, |e| matches!(e.payload, Event::OrderBook(_))).await;
409    if env.source.as_ref() != source {
410        fail(S, format!("source {:?} != {source:?}", env.source));
411    }
412    if env.sub != Some(sub) {
413        fail(
414            S,
415            format!("envelope sub {:?} != subscription {sub:?}", env.sub),
416        );
417    }
418    let Event::OrderBook(book) = &env.payload else {
419        unreachable!()
420    };
421    if book_sides(book).0.key != id.key {
422        fail(S, "order book for a different instrument");
423    }
424    sub
425}
426
427/// Unsubscribing an active subscription succeeds, and doing it again is refused the same way at every broker.
428///
429/// The first call must return `Ok`. The second call, for an id that is already closed, must return
430/// [`Error::Rejected`] with code `unknown_subscription`: not `Ok`, not a transport error, not a panic.
431/// `sub` is the id of a live subscription, for example the one [`order_book_stream`] returned.
432///
433/// # Panics
434///
435/// With `[conformance:unsubscribe] ...` when the first call returns an error, or the second one is
436/// not the refusal described above.
437///
438/// # Examples
439///
440/// ```
441/// # use brokapi_bcs::limits::default_limits;
442/// # use brokapi_bcs::{MarketDataConfig, MarketDataConnector};
443/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
444/// # use brokapi_runtime::BrokerLimiter;
445/// # #[tokio::main(flavor = "current_thread")]
446/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
447/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
448/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
449/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
450/// # let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "bcs-md"), BrokerLimiter::new(&default_limits()), tx).await?;
451/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
452/// let sub = brokapi_conformance::order_book_stream(&md, &mut rx, "bcs-md", &sber).await;
453/// brokapi_conformance::unsubscribe(&md, sub).await;
454/// # md.shutdown(ShutdownMode::Immediate).await;
455/// # Ok(())
456/// # }
457/// ```
458pub async fn unsubscribe<M: OrderBookSource + ?Sized>(md: &M, sub: SubscriptionId) {
459    const S: &str = "unsubscribe";
460    md.unsubscribe(sub)
461        .await
462        .unwrap_or_else(|e| fail(S, format!("unsubscribe failed: {e:?}")));
463    expect_unknown_subscription(S, md.unsubscribe(sub).await);
464}
465
466/// Unsubscribing an id the connector never gave out is refused: [`Error::Rejected`] with code
467/// `unknown_subscription`. The same answer at every broker, so a bot needs one branch for it.
468///
469/// # Panics
470///
471/// With `[conformance:unsubscribe_unknown] ...` when the answer is anything else.
472///
473/// # Examples
474///
475/// ```
476/// # use brokapi_bcs::limits::default_limits;
477/// # use brokapi_bcs::{MarketDataConfig, MarketDataConnector};
478/// # use brokapi_core::ShutdownMode;
479/// # use brokapi_runtime::BrokerLimiter;
480/// # #[tokio::main(flavor = "current_thread")]
481/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
482/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
483/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
484/// # let (tx, _rx) = tokio::sync::mpsc::channel(256);
485/// # let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "bcs-md"), BrokerLimiter::new(&default_limits()), tx).await?;
486/// brokapi_conformance::unsubscribe_unknown(&md).await;
487/// # md.shutdown(ShutdownMode::Immediate).await;
488/// # Ok(())
489/// # }
490/// ```
491pub async fn unsubscribe_unknown<M: OrderBookSource + ?Sized>(md: &M) {
492    expect_unknown_subscription(
493        "unsubscribe_unknown",
494        md.unsubscribe(SubscriptionId::new(u64::MAX - 1)).await,
495    );
496}
497
498fn expect_unknown_subscription(scenario: &str, outcome: Result<(), Error>) {
499    match outcome {
500        Err(Error::Rejected { code, .. }) if code == "unknown_subscription" => {}
501        other => fail(
502            scenario,
503            format!(
504                "unsubscribe of an unknown id must be Rejected(unknown_subscription), got {other:?}"
505            ),
506        ),
507    }
508}
509
510/// Instrument directory: a search finds the instrument, and its trading parameters are usable for order sizing.
511///
512/// Checks:
513/// - `md.find(query)` succeeds and the result contains an instrument with the same
514///   [`brokapi_core::InstrumentKey`] as `id`;
515/// - `md.trading_params(id)` succeeds with `lot_size > 0` and `price_step > 0`. Both are used to round
516///   order quantities and prices, so zero would make every order invalid.
517///
518/// `query` must be a search that finds `id`. The mock must list that instrument and serve its parameters.
519///
520/// # Panics
521///
522/// With `[conformance:instrument_lookup] <reason>` when a call fails, the instrument is not found, or a
523/// parameter is not positive.
524///
525/// # Examples
526///
527/// ```
528/// # use brokapi_bcs::limits::default_limits;
529/// # use brokapi_bcs::{MarketDataConfig, MarketDataConnector};
530/// # use brokapi_core::{InstrumentQuery, InstrumentId, InstrumentKey, NativeId, ShutdownMode};
531/// # use brokapi_runtime::BrokerLimiter;
532/// # #[tokio::main(flavor = "current_thread")]
533/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
534/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
535/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
536/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
537/// # let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "bcs-md"), BrokerLimiter::new(&default_limits()), tx).await?;
538/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
539/// let query = InstrumentQuery::ByTicker { ticker: "SBER".into(), exchange: None };
540/// brokapi_conformance::instrument_lookup(&md, query, &sber).await;
541/// # md.shutdown(ShutdownMode::Immediate).await;
542/// # Ok(())
543/// # }
544/// ```
545pub async fn instrument_lookup<M: InstrumentSource + ?Sized>(
546    md: &M,
547    query: InstrumentQuery,
548    id: &InstrumentId,
549) {
550    const S: &str = "instrument_lookup";
551    let found = md
552        .find(query)
553        .await
554        .unwrap_or_else(|e| fail(S, format!("find failed: {e:?}")));
555    if !found.iter().any(|i| i.id.key == id.key) {
556        fail(S, format!("find did not return {}", id.key.display()));
557    }
558    let params = md
559        .trading_params(id)
560        .await
561        .unwrap_or_else(|e| fail(S, format!("trading_params failed: {e:?}")));
562    if !params.lot_size.is_positive() || !params.price_step.is_positive() {
563        fail(
564            S,
565            format!(
566                "lot_size {} / price_step {} must be > 0",
567                params.lot_size, params.price_step
568            ),
569        );
570    }
571}
572
573/// Places a far-from-market limit order, reads its state by cid, cancels it.
574///
575/// Steps and checks:
576/// 1. `place` a buy limit order, one lot, at `price`, with a fresh cid. The ack must carry the same
577///    cid and a non-empty broker order id.
578/// 2. `order_state(cid)` must return a snapshot for that cid that is not `Rejected`.
579/// 3. `cancel(cid)` must succeed.
580///
581/// `price` must be far below the market so the order rests and does not fill, and must be a price the
582/// mock accepts (not [`REJECT_PRICE`]). There is no retry: a local rate-limit refusal is a failure
583/// here. The command gate must be open, so call [`reconcile`] first.
584///
585/// The mock must acknowledge a limit order, report its state, and cancel it.
586///
587/// # Panics
588///
589/// With `[conformance:place_and_cancel] <reason>` when `place`, `order_state` or `cancel` fails, or the
590/// ack or snapshot is wrong. A `NotReconciled` error here means [`reconcile`] was not run first.
591///
592/// # Examples
593///
594/// ```
595/// # use brokapi_core::{Price};
596/// # use brokapi_bcs::limits::default_limits;
597/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
598/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
599/// # use brokapi_runtime::BrokerLimiter;
600/// # #[tokio::main(flavor = "current_thread")]
601/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
602/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
603/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
604/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
605/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
606/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
607/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
608/// brokapi_conformance::reconcile(&ud).await;
609/// brokapi_conformance::place_and_cancel(&ud, &sber, Price::from(1)).await;
610/// # ud.shutdown(ShutdownMode::Immediate).await;
611/// # Ok(())
612/// # }
613/// ```
614pub async fn place_and_cancel<U: OrderExecutor + ?Sized>(ud: &U, id: &InstrumentId, price: Price) {
615    const S: &str = "place_and_cancel";
616    let cid = ClientOrderId::new_v4();
617    let ack = ud
618        .place(PlaceOrderRequest {
619            cid,
620            instrument: id.clone(),
621            side: Side::Buy,
622            order_type: OrderType::Limit,
623            qty: lots(1),
624            price: Some(price),
625        })
626        .await
627        .unwrap_or_else(|e| fail(S, format!("place failed: {e:?}")));
628    if ack.cid != cid {
629        fail(S, format!("ack cid {} != sent {cid}", ack.cid));
630    }
631    if ack.broker_id.as_str().is_empty() {
632        fail(S, "ack without broker order id");
633    }
634    let snap = ud
635        .order_state(cid)
636        .await
637        .unwrap_or_else(|e| fail(S, format!("order_state failed: {e:?}")));
638    if snap.cid != cid {
639        fail(S, format!("order_state cid {} != {cid}", snap.cid));
640    }
641    if matches!(snap.state, OrderState::Rejected { .. }) {
642        fail(S, format!("order rejected: {:?}", snap.state));
643    }
644    ud.cancel(cid)
645        .await
646        .unwrap_or_else(|e| fail(S, format!("cancel failed: {e:?}")));
647}
648
649/// `order_state` of a `cid` the broker has never seen is a refusal: [`Error::Rejected`] with code
650/// `not_found` ([`brokapi_core::NOT_FOUND`]), spelled the same at every broker.
651///
652/// The same answer at every broker, so a bot that recovers from [`Error::Unknown`] with `order_state`
653/// needs one branch for "the broker does not know the order (yet)". Not `Transport`, not `Ok`.
654///
655/// # Panics
656///
657/// With `[conformance:order_state_unknown_cid] ...` when the answer is anything else.
658///
659/// # Examples
660///
661/// ```
662/// # use brokapi_bcs::limits::default_limits;
663/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
664/// # use brokapi_core::ShutdownMode;
665/// # use brokapi_runtime::BrokerLimiter;
666/// # #[tokio::main(flavor = "current_thread")]
667/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
668/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
669/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
670/// # let (tx, _rx) = tokio::sync::mpsc::channel(256);
671/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
672/// brokapi_conformance::order_state_unknown_cid(&ud).await;
673/// # ud.shutdown(ShutdownMode::Immediate).await;
674/// # Ok(())
675/// # }
676/// ```
677pub async fn order_state_unknown_cid<U: OrderExecutor + ?Sized>(ud: &U) {
678    const S: &str = "order_state_unknown_cid";
679    match ud.order_state(ClientOrderId::new_v4()).await {
680        Err(e) if e.is_not_found() => {}
681        other => fail(
682            S,
683            format!("order_state of an unknown cid must be Rejected(not_found), got {other:?}"),
684        ),
685    }
686}
687
688fn limit_buy(id: &InstrumentId, price: Price) -> PlaceOrderRequest {
689    PlaceOrderRequest {
690        cid: ClientOrderId::new_v4(),
691        instrument: id.clone(),
692        side: Side::Buy,
693        order_type: OrderType::Limit,
694        qty: lots(1),
695        price: Some(price),
696    }
697}
698
699/// Places a limit order; a local rate-limit refusal (nothing was sent) is waited out and retried
700/// under a fresh cid. Returns the cid that was finally used.
701async fn place_patient<U: OrderExecutor + ?Sized>(
702    ud: &U,
703    id: &InstrumentId,
704    price: Price,
705) -> (
706    ClientOrderId,
707    Result<brokapi_core::OrderAck, brokapi_core::Error>,
708) {
709    for _ in 0..20 {
710        let req = limit_buy(id, price);
711        let cid = req.cid;
712        match ud.place(req).await {
713            Err(brokapi_core::Error::RateLimited { retry_after }) => {
714                tokio::time::sleep(retry_after).await
715            }
716            other => return (cid, other),
717        }
718    }
719    fail("place_patient", "still rate limited after 20 waits")
720}
721
722/// An order the broker refuses must not block the instrument: the next order on the same instrument goes through.
723///
724/// This guards a bug BCS once had: a failed place left the per-instrument queue busy, and every later
725/// order got "command queued" until restart. Two rounds:
726/// 1. One after another: a limit order at [`REJECT_PRICE`] must fail with an error other than
727///    [`brokapi_core::Error::Unknown`] (a refusal is a clear answer, not a lost one). Then an order at
728///    `ok_price` must succeed and is cancelled.
729/// 2. At the same time (`tokio::join!`): the refused order and the good order are in flight together.
730///    The refused one must fail, the good one must succeed and is cancelled.
731///
732/// Every place waits at most [`EVENT_TIMEOUT`], so a stuck queue fails the scenario instead of hanging.
733/// A local rate-limit refusal (nothing was sent) is waited out and the order is retried under a fresh
734/// cid, up to 20 times. A cid is never sent twice, because a repeated place could create a second order.
735///
736/// `ok_price` is a far-from-market price the mock accepts. The mock must refuse [`REJECT_PRICE`] with a
737/// definite refusal. The command gate must be open, so call [`reconcile`] first.
738///
739/// # Panics
740///
741/// With `[conformance:rejected_order_does_not_block_the_instrument] <reason>` when the refused order is
742/// accepted, is reported as `Unknown`, when a later order fails or hangs, or when a cancel fails.
743///
744/// # Examples
745///
746/// ```
747/// # use brokapi_core::{Price};
748/// # use brokapi_bcs::limits::default_limits;
749/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
750/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
751/// # use brokapi_runtime::BrokerLimiter;
752/// # #[tokio::main(flavor = "current_thread")]
753/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
754/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
755/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
756/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
757/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
758/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
759/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
760/// brokapi_conformance::reconcile(&ud).await;
761/// brokapi_conformance::rejected_order_does_not_block_the_instrument(&ud, &sber, Price::from(1)).await;
762/// # ud.shutdown(ShutdownMode::Immediate).await;
763/// # Ok(())
764/// # }
765/// ```
766pub async fn rejected_order_does_not_block_the_instrument<U: OrderExecutor + ?Sized>(
767    ud: &U,
768    id: &InstrumentId,
769    ok_price: Price,
770) {
771    const S: &str = "rejected_order_does_not_block_the_instrument";
772
773    // One after another: refused, then accepted.
774    let (_, refused) = timeout(EVENT_TIMEOUT, place_patient(ud, id, REJECT_PRICE))
775        .await
776        .unwrap_or_else(|_| fail(S, "place of the refused order hung"));
777    match refused {
778        Ok(_) => fail(
779            S,
780            "the mock refuses price REJECT_PRICE, but place succeeded",
781        ),
782        Err(brokapi_core::Error::Unknown) => fail(S, "a refusal by the broker must not be Unknown"),
783        Err(_) => {}
784    }
785    let (cid, accepted) = timeout(EVENT_TIMEOUT, place_patient(ud, id, ok_price))
786        .await
787        .unwrap_or_else(|_| {
788            fail(
789                S,
790                "place after a refused order hung (instrument still locked?)",
791            )
792        });
793    accepted.unwrap_or_else(|e| fail(S, format!("place after a refused order failed: {e:?}")));
794    ud.cancel(cid)
795        .await
796        .unwrap_or_else(|e| fail(S, format!("cancel failed: {e:?}")));
797
798    // At the same time: both are in flight together, the refused one must not take the other down.
799    let both = async {
800        tokio::join!(
801            place_patient(ud, id, REJECT_PRICE),
802            place_patient(ud, id, ok_price)
803        )
804    };
805    let ((_, refused), (cid, accepted)) = timeout(EVENT_TIMEOUT, both)
806        .await
807        .unwrap_or_else(|_| fail(S, "concurrent places hung"));
808    if refused.is_ok() {
809        fail(S, "concurrent: the order at REJECT_PRICE was accepted");
810    }
811    accepted.unwrap_or_else(|e| {
812        fail(
813            S,
814            format!("concurrent: the order after the refused one failed: {e:?}"),
815        )
816    });
817    ud.cancel(cid)
818        .await
819        .unwrap_or_else(|e| fail(S, format!("cancel failed: {e:?}")));
820}
821
822/// Reconcile: an empty ledger touches nothing, and an unknown cid is reported as closed offline.
823///
824/// Checks:
825/// - `reconcile(&[])` succeeds and reports no `confirmed` and no `closed_offline` orders;
826/// - `reconcile(&[ghost])` with a random cid the broker has never seen succeeds and lists the cid in
827///   `closed_offline`.
828///
829/// `foreign` orders are not checked. The account at the mock must have no orders of ours.
830///
831/// A successful reconcile also opens the command gate of the connector. That is why this scenario is
832/// the first step of every scenario that sends trading commands: before it, those commands fail with
833/// [`brokapi_core::Error::NotReconciled`].
834///
835/// # Panics
836///
837/// With `[conformance:reconcile] <reason>` when a call fails or the report is wrong.
838///
839/// # Examples
840///
841/// ```
842/// # use brokapi_bcs::limits::default_limits;
843/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
844/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
845/// # use brokapi_runtime::BrokerLimiter;
846/// # #[tokio::main(flavor = "current_thread")]
847/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
848/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
849/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
850/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
851/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
852/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
853/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
854/// brokapi_conformance::reconcile(&ud).await;
855/// # ud.shutdown(ShutdownMode::Immediate).await;
856/// # Ok(())
857/// # }
858/// ```
859pub async fn reconcile<U: OrderExecutor + ?Sized>(ud: &U) {
860    const S: &str = "reconcile";
861    let report = ud
862        .reconcile(&[])
863        .await
864        .unwrap_or_else(|e| fail(S, format!("reconcile([]) failed: {e:?}")));
865    if !report.confirmed.is_empty() || !report.closed_offline.is_empty() {
866        fail(
867            S,
868            "empty ledger must give no confirmed and no closed_offline",
869        );
870    }
871    let ghost = ClientOrderId::new_v4();
872    let report = ud
873        .reconcile(&[ghost])
874        .await
875        .unwrap_or_else(|e| fail(S, format!("reconcile([ghost]) failed: {e:?}")));
876    if !report.closed_offline.contains(&ghost) {
877        fail(
878            S,
879            "cid unknown to the broker must be reported in closed_offline",
880        );
881    }
882}
883
884/// Portfolio: the snapshot works, and a portfolio update arrives after subscribe.
885///
886/// Calls `portfolio()`, then `subscribe_portfolio` with [`brokapi_core::SubscribeOpts::lossless`] (64),
887/// then waits (up to [`EVENT_TIMEOUT`]) for the first `Portfolio`, `Positions` or `Balances` event on
888/// `rx`. That envelope must have `source` as its source and the id returned by the subscribe as its `sub`.
889///
890/// The mock must serve a portfolio and push at least one update after subscribe. The subscription is
891/// left open.
892///
893/// # Panics
894///
895/// With `[conformance:portfolio] <reason>` when a call fails, no update arrives, or source or sub is
896/// wrong.
897///
898/// # Examples
899///
900/// ```
901/// # use brokapi_bcs::limits::default_limits;
902/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
903/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
904/// # use brokapi_runtime::BrokerLimiter;
905/// # #[tokio::main(flavor = "current_thread")]
906/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
907/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
908/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
909/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
910/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
911/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
912/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
913/// brokapi_conformance::portfolio(&ud, &mut rx, "bcs-ud").await;
914/// # ud.shutdown(ShutdownMode::Immediate).await;
915/// # Ok(())
916/// # }
917/// ```
918pub async fn portfolio<U: PortfolioSource + ?Sized>(
919    ud: &U,
920    rx: &mut mpsc::Receiver<Envelope>,
921    source: &str,
922) {
923    const S: &str = "portfolio";
924    ud.portfolio()
925        .await
926        .unwrap_or_else(|e| fail(S, format!("portfolio failed: {e:?}")));
927    let sub = ud
928        .subscribe_portfolio(SubscribeOpts::lossless(64))
929        .await
930        .unwrap_or_else(|e| fail(S, format!("subscribe_portfolio failed: {e:?}")));
931    let env = next_matching(S, rx, |e| {
932        matches!(
933            e.payload,
934            Event::Portfolio(_) | Event::Positions(_) | Event::Balances(_)
935        )
936    })
937    .await;
938    if env.source.as_ref() != source {
939        fail(S, format!("source {:?} != {source:?}", env.source));
940    }
941    if env.sub != Some(sub) {
942        fail(
943            S,
944            format!("envelope sub {:?} != subscription {sub:?}", env.sub),
945        );
946    }
947}
948
949fn stop_request(
950    id: &InstrumentId,
951    kind: StopOrderKind,
952    stop: Price,
953    limit: Option<Price>,
954) -> PlaceStopOrderRequest {
955    PlaceStopOrderRequest {
956        cid: ClientOrderId::new_v4(),
957        instrument: id.clone(),
958        side: Side::Sell,
959        kind,
960        qty: lots(1),
961        stop_price: stop,
962        limit_price: limit,
963        expiry: StopExpiry::GoodTillCancel,
964    }
965}
966
967/// Stop orders, the common path: place, list, cancel by cid, then a stop-limit cancelled by broker id.
968///
969/// Steps and checks:
970/// 1. A stop-loss SELL, one lot, at `stop_price`. The ack carries the same cid and a non-empty broker
971///    id. The order is then in `active_stop_orders()` with the same cid, kind `StopLoss`, side `Sell`,
972///    the stop price and quantity we sent, no limit price, and state `Active`. `cancel_stop` by
973///    [`brokapi_core::StopOrderRef::Cid`] succeeds and the order leaves the list.
974/// 2. A stop-limit SELL with limit price `stop_price * 0.99`. It appears in the list with kind
975///    `StopLimit` and a limit price. It is cancelled by [`brokapi_core::StopOrderRef::Broker`] and
976///    leaves the list.
977/// 3. A stop-limit without a limit price is refused with [`brokapi_core::Error::Rejected`] before
978///    anything is sent, and the active list is empty afterwards (so the mock must start with no stop
979///    orders).
980///
981/// `stop_price` must be far BELOW the market: the orders are SELL stops, they must not trigger. The
982/// command gate must be open, so call [`reconcile`] first. There is no rate-limit retry in this scenario.
983///
984/// The mock must accept a stop order, list it while it waits, and cancel it.
985///
986/// # Panics
987///
988/// With `[conformance:stop_order_far_from_market] <reason>` when any check above fails.
989///
990/// # Examples
991///
992/// ```
993/// # use brokapi_core::{Price};
994/// # use brokapi_bcs::limits::default_limits;
995/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
996/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
997/// # use brokapi_runtime::BrokerLimiter;
998/// # #[tokio::main(flavor = "current_thread")]
999/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1000/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
1001/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
1002/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
1003/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
1004/// # let mut limits = default_limits();
1005/// # for bucket in limits.buckets.values_mut() { bucket.burst = 100; } // the scenario is quick, it tests the connector and not the budget
1006/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&limits), tx).await?;
1007/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
1008/// brokapi_conformance::reconcile(&ud).await;
1009/// assert!(ud.caps().supports_stop_orders);
1010/// brokapi_conformance::stop_order_far_from_market(&ud, &sber, Price::from(200)).await;
1011/// # ud.shutdown(ShutdownMode::Immediate).await;
1012/// # Ok(())
1013/// # }
1014/// ```
1015pub async fn stop_order_far_from_market<U: StopOrderExecutor + ?Sized>(
1016    ud: &U,
1017    id: &InstrumentId,
1018    stop_price: Price,
1019) {
1020    const S: &str = "stop_order_far_from_market";
1021
1022    // 1. Stop-loss: place, list, cancel by cid.
1023    let req = stop_request(id, StopOrderKind::StopLoss, stop_price, None);
1024    let cid = req.cid;
1025    let ack = ud
1026        .place_stop(req)
1027        .await
1028        .unwrap_or_else(|e| fail(S, format!("place_stop failed: {e:?}")));
1029    if ack.cid != cid {
1030        fail(S, format!("ack cid {} != sent {cid}", ack.cid));
1031    }
1032    if ack.broker_id.as_str().is_empty() {
1033        fail(S, "ack without broker stop order id");
1034    }
1035    let active = ud
1036        .active_stop_orders()
1037        .await
1038        .unwrap_or_else(|e| fail(S, format!("active_stop_orders failed: {e:?}")));
1039    let Some(snap) = active.iter().find(|s| s.broker_id == ack.broker_id) else {
1040        fail(
1041            S,
1042            format!(
1043                "the stop order {} is not in the active list: {active:?}",
1044                ack.broker_id.as_str()
1045            ),
1046        );
1047    };
1048    if snap.cid != Some(cid) {
1049        fail(
1050            S,
1051            format!("the list shows cid {:?}, we placed {cid}", snap.cid),
1052        );
1053    }
1054    if snap.kind != StopOrderKind::StopLoss || snap.side != Side::Sell {
1055        fail(
1056            S,
1057            format!("kind/side lost: {:?} {:?}", snap.kind, snap.side),
1058        );
1059    }
1060    if snap.stop_price != stop_price || snap.qty != lots(1) {
1061        fail(
1062            S,
1063            format!("price/qty lost: stop {} qty {}", snap.stop_price, snap.qty),
1064        );
1065    }
1066    if snap.limit_price.is_some() {
1067        fail(S, "a stop-loss without a limit price came back with one");
1068    }
1069    if snap.state != StopOrderState::Active {
1070        fail(
1071            S,
1072            format!("a waiting stop order must be Active, got {:?}", snap.state),
1073        );
1074    }
1075    ud.cancel_stop(StopOrderRef::Cid(cid))
1076        .await
1077        .unwrap_or_else(|e| fail(S, format!("cancel_stop by cid failed: {e:?}")));
1078    let after = ud
1079        .active_stop_orders()
1080        .await
1081        .unwrap_or_else(|e| fail(S, format!("list after cancel failed: {e:?}")));
1082    if after.iter().any(|s| s.broker_id == ack.broker_id) {
1083        fail(S, "the stop order is still active after cancel_stop");
1084    }
1085
1086    // 2. Stop-limit with a limit price, cancelled by the broker id.
1087    let req = stop_request(
1088        id,
1089        StopOrderKind::StopLimit,
1090        stop_price,
1091        Some(stop_price * Decimal::new(99, 2)),
1092    );
1093    let ack2 = ud
1094        .place_stop(req)
1095        .await
1096        .unwrap_or_else(|e| fail(S, format!("place_stop (stop-limit) failed: {e:?}")));
1097    let active = ud
1098        .active_stop_orders()
1099        .await
1100        .unwrap_or_else(|e| fail(S, format!("list failed: {e:?}")));
1101    let Some(snap2) = active.iter().find(|s| s.broker_id == ack2.broker_id) else {
1102        fail(S, "the stop-limit order is not in the active list");
1103    };
1104    if snap2.kind != StopOrderKind::StopLimit || snap2.limit_price.is_none() {
1105        fail(
1106            S,
1107            format!("stop-limit lost its kind or limit price: {snap2:?}"),
1108        );
1109    }
1110    ud.cancel_stop(StopOrderRef::Broker(ack2.broker_id.clone()))
1111        .await
1112        .unwrap_or_else(|e| fail(S, format!("cancel_stop by broker id failed: {e:?}")));
1113    let after = ud
1114        .active_stop_orders()
1115        .await
1116        .unwrap_or_else(|e| fail(S, format!("list failed: {e:?}")));
1117    if after.iter().any(|s| s.broker_id == ack2.broker_id) {
1118        fail(S, "the stop-limit order is still active after cancel_stop");
1119    }
1120
1121    // 3. A stop-limit needs a limit price: refused locally, nothing sent, nothing listed.
1122    let bad = stop_request(id, StopOrderKind::StopLimit, stop_price, None);
1123    match ud.place_stop(bad).await {
1124        Err(Error::Rejected { .. }) => {}
1125        other => fail(
1126            S,
1127            format!("stop-limit without a limit price must be Rejected, got {other:?}"),
1128        ),
1129    }
1130    let after = ud
1131        .active_stop_orders()
1132        .await
1133        .unwrap_or_else(|e| fail(S, format!("list failed: {e:?}")));
1134    if !after.is_empty() {
1135        fail(S, format!("nothing should be left, found {after:?}"));
1136    }
1137}
1138
1139/// A stop order the broker took, whose answer was lost, comes back in the list with its `cid`.
1140///
1141/// The failure that costs money: the stop order is at the broker and the bot was told nothing. `lose` turns the
1142/// fault of the imitation on (`true`: the broker takes a stop order, then the answer is lost) and off (`false`).
1143/// The connector must not send again and must not lose the link between the stop order and the `cid`. Steps:
1144/// 1. With the fault on, `place_stop` is [`Error::Unknown`].
1145/// 2. The same `cid` again is `Rejected(duplicate_place)`: a stop order is never sent twice.
1146/// 3. `active_stop_orders` shows exactly one stop order, and its `cid` is the one that was sent. Whether the
1147///    broker returns the number or the connector finds the stop order by what was asked for, the bot sees the
1148///    same.
1149/// 4. `cancel_stop` by that `cid` removes it.
1150///
1151/// # Panics
1152///
1153/// With `[conformance:stop_without_answer_keeps_its_cid] <reason>`.
1154///
1155/// # Examples
1156///
1157/// ```
1158/// # use std::sync::atomic::Ordering;
1159/// # use brokapi_core::Price;
1160/// # use brokapi_bcs::limits::default_limits;
1161/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
1162/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
1163/// # use brokapi_runtime::BrokerLimiter;
1164/// # #[tokio::main(flavor = "current_thread")]
1165/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1166/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
1167/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
1168/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
1169/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
1170/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
1171/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
1172/// brokapi_conformance::reconcile(&ud).await;
1173/// brokapi_conformance::stop_without_answer_keeps_its_cid(&ud, &sber, Price::from(200), |lose| {
1174///     opts.stop_answer_lost.store(lose, Ordering::SeqCst)
1175/// })
1176/// .await;
1177/// # ud.shutdown(ShutdownMode::Immediate).await;
1178/// # Ok(())
1179/// # }
1180/// ```
1181pub async fn stop_without_answer_keeps_its_cid<U: StopOrderExecutor + ?Sized>(
1182    ud: &U,
1183    id: &InstrumentId,
1184    stop_price: Price,
1185    lose: impl Fn(bool),
1186) {
1187    const S: &str = "stop_without_answer_keeps_its_cid";
1188    let req = stop_request(id, StopOrderKind::StopLoss, stop_price, None);
1189    let cid = req.cid;
1190
1191    lose(true);
1192    let placed = ud.place_stop(req.clone()).await;
1193    lose(false);
1194    match placed {
1195        Err(Error::Unknown) => {}
1196        other => fail(
1197            S,
1198            format!("a stop order whose answer was lost must be Unknown, got {other:?}"),
1199        ),
1200    }
1201    match ud.place_stop(req).await {
1202        Err(Error::Rejected { code, .. }) if code == "duplicate_place" => {}
1203        other => fail(
1204            S,
1205            format!(
1206                "a stop order is never sent twice: expected Rejected(duplicate_place), got {other:?}"
1207            ),
1208        ),
1209    }
1210    let active = ud
1211        .active_stop_orders()
1212        .await
1213        .unwrap_or_else(|e| fail(S, format!("active_stop_orders failed: {e:?}")));
1214    match active.as_slice() {
1215        [one] if one.cid == Some(cid) => {}
1216        other => fail(
1217            S,
1218            format!("the list must hold the one stop order, with cid {cid}, got {other:?}"),
1219        ),
1220    }
1221    ud.cancel_stop(StopOrderRef::Cid(cid))
1222        .await
1223        .unwrap_or_else(|e| fail(S, format!("cancel_stop by the cid failed: {e:?}")));
1224    let after = ud
1225        .active_stop_orders()
1226        .await
1227        .unwrap_or_else(|e| fail(S, format!("list after cancel failed: {e:?}")));
1228    if !after.is_empty() {
1229        fail(S, format!("nothing should be left, found {after:?}"));
1230    }
1231}
1232
1233/// A connector without stop orders says so: every stop method answers [`brokapi_core::Error::Unsupported`].
1234///
1235/// Checks that `place_stop`, `cancel_stop`, `active_stop_orders` and `subscribe_stop_orders` each
1236/// return `Unsupported` (any other result, success or another error, fails). No adapter uses this
1237/// today, because all three map stop orders. It stays for a future adapter whose broker has none. Do
1238/// not run it for an adapter that supports stop orders.
1239///
1240/// # Panics
1241///
1242/// With `[conformance:stop_orders_unsupported] <method> must answer Unsupported, got ...` for the first
1243/// method that answers anything else.
1244///
1245/// # Examples
1246///
1247/// The BCS connector supports stop orders, so this scenario fails against it. That is the failure the
1248/// scenario exists to catch:
1249///
1250/// ```should_panic(expected = "[conformance:stop_orders_unsupported] place_stop must answer Unsupported")
1251/// # use brokapi_bcs::limits::default_limits;
1252/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
1253/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
1254/// # use brokapi_runtime::BrokerLimiter;
1255/// # #[tokio::main(flavor = "current_thread")]
1256/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1257/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
1258/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
1259/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
1260/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
1261/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
1262/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
1263/// // panics: [conformance:stop_orders_unsupported] ... must answer Unsupported, got ...
1264/// brokapi_conformance::stop_orders_unsupported(&ud, &sber).await;
1265/// # ud.shutdown(ShutdownMode::Immediate).await;
1266/// # Ok(())
1267/// # }
1268/// ```
1269pub async fn stop_orders_unsupported<U: StopOrderExecutor + ?Sized>(ud: &U, id: &InstrumentId) {
1270    const S: &str = "stop_orders_unsupported";
1271    let req = stop_request(id, StopOrderKind::StopLoss, Price::from(1), None);
1272    let cid = req.cid;
1273    let checks: [(&str, Result<(), Error>); 4] = [
1274        ("place_stop", ud.place_stop(req).await.map(|_| ())),
1275        ("cancel_stop", ud.cancel_stop(StopOrderRef::Cid(cid)).await),
1276        (
1277            "active_stop_orders",
1278            ud.active_stop_orders().await.map(|_| ()),
1279        ),
1280        (
1281            "subscribe_stop_orders",
1282            ud.subscribe_stop_orders().await.map(|_| ()),
1283        ),
1284    ];
1285    for (name, res) in checks {
1286        if !matches!(res, Err(Error::Unsupported { .. })) {
1287            fail(S, format!("{name} must answer Unsupported, got {res:?}"));
1288        }
1289    }
1290}
1291
1292// ─── market data reads and streams ────────────────────────────────────────────
1293
1294/// The tape, candles, trading status and schedule of one instrument.
1295///
1296/// - `last_trades(limit)` gives at least one trade and never more than `limit`. Prices and quantities
1297///   are positive and the trades are for the right instrument.
1298/// - `candles(tf, candle_range)` gives bars with `low <= open, close <= high`, closing after they open,
1299///   for the asked timeframe and instrument.
1300/// - `trading_status` names the instrument and is not `Unknown`.
1301/// - `schedule(schedule_key, schedule_range)` gives sessions that start before they end. `schedule_key`
1302///   is what the adapter calls an exchange (T-Bank `"MOEX"`, Finam and BCS `"TICKER@MARKET"`).
1303///
1304/// The scenario asks for `last_trades` with a limit of 2. The mock must serve the tape, candles for
1305/// `candle_range`, a known trading status and at least one session in `schedule_range`.
1306///
1307/// # Panics
1308///
1309/// With `[conformance:market_data_history] <reason>` when a call fails or a check above fails.
1310///
1311/// # Examples
1312///
1313/// ```
1314/// # use brokapi_bcs::limits::default_limits;
1315/// # use brokapi_bcs::{MarketDataConfig, MarketDataConnector};
1316/// # use brokapi_core::{TimeRange, Timeframe, UnixNanos, InstrumentId, InstrumentKey, NativeId, ShutdownMode};
1317/// # use brokapi_runtime::BrokerLimiter;
1318/// # #[tokio::main(flavor = "current_thread")]
1319/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1320/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
1321/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
1322/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
1323/// # let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "bcs-md"), BrokerLimiter::new(&default_limits()), tx).await?;
1324/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
1325/// // 2026-10-19 00:00 UTC .. 2026-10-20 00:00 UTC
1326/// let day = TimeRange::new(
1327///     UnixNanos::from_nanos(1_792_368_000_000_000_000),
1328///     UnixNanos::from_nanos(1_792_454_400_000_000_000),
1329/// );
1330/// // BCS schedules are per instrument: "TICKER@CLASSCODE".
1331/// brokapi_conformance::market_data_history(&md, &sber, Timeframe::M1, day, "SBER@TQBR", day).await;
1332/// # md.shutdown(ShutdownMode::Immediate).await;
1333/// # Ok(())
1334/// # }
1335/// ```
1336pub async fn market_data_history<M: MarketDataSource + InstrumentSource + ?Sized>(
1337    md: &M,
1338    id: &InstrumentId,
1339    tf: Timeframe,
1340    candle_range: TimeRange,
1341    schedule_key: &str,
1342    schedule_range: TimeRange,
1343) {
1344    const S: &str = "market_data_history";
1345    market_data_history_without_schedule(md, id, tf, candle_range).await;
1346    let sched = md
1347        .schedule(schedule_key, schedule_range)
1348        .await
1349        .unwrap_or_else(|e| fail(S, format!("schedule({schedule_key:?}) failed: {e:?}")));
1350    if sched.sessions.is_empty() {
1351        fail(S, "the mock must serve at least one session");
1352    }
1353    if sched
1354        .sessions
1355        .iter()
1356        .any(|x| x.start.as_nanos() >= x.end.as_nanos())
1357    {
1358        fail(
1359            S,
1360            format!("a session starts after it ends: {:?}", sched.sessions),
1361        );
1362    }
1363}
1364
1365/// The tape, candles and trading status of one instrument: [`market_data_history`] without the schedule, for a
1366/// broker whose API has no timetable of sessions (its `schedule` answers `Unsupported`, which this scenario
1367/// does not call).
1368///
1369/// # Panics
1370///
1371/// With `[conformance:market_data_history] <reason>` when a call fails or a check of
1372/// [`market_data_history`] (the first three) fails.
1373pub async fn market_data_history_without_schedule<
1374    M: MarketDataSource + InstrumentSource + ?Sized,
1375>(
1376    md: &M,
1377    id: &InstrumentId,
1378    tf: Timeframe,
1379    candle_range: TimeRange,
1380) {
1381    const S: &str = "market_data_history";
1382    let trades = md
1383        .last_trades(id, 2)
1384        .await
1385        .unwrap_or_else(|e| fail(S, format!("last_trades failed: {e:?}")));
1386    if trades.is_empty() {
1387        fail(S, "the mock must serve at least one trade");
1388    }
1389    if trades.len() > 2 {
1390        fail(
1391            S,
1392            format!("last_trades(limit 2) returned {} trades", trades.len()),
1393        );
1394    }
1395    for t in &trades {
1396        if !(t.price.is_positive() && t.qty.is_positive()) {
1397            fail(S, format!("trade with price {} qty {}", t.price, t.qty));
1398        }
1399        if t.instrument.key != id.key {
1400            fail(
1401                S,
1402                format!(
1403                    "trade of {} for {}",
1404                    t.instrument.key.display(),
1405                    id.key.display()
1406                ),
1407            );
1408        }
1409    }
1410
1411    let bars = md
1412        .candles(id, tf, candle_range)
1413        .await
1414        .unwrap_or_else(|e| fail(S, format!("candles failed: {e:?}")));
1415    if bars.is_empty() {
1416        fail(S, "the mock must serve candles");
1417    }
1418    for c in &bars {
1419        let (lo, hi) = (c.low, c.high);
1420        if !(lo <= c.open && c.open <= hi && lo <= c.close && c.close <= hi) {
1421            fail(S, format!("candle OHLC is inconsistent: {c:?}"));
1422        }
1423        if c.close_ts.as_nanos() < c.open_ts.as_nanos() {
1424            fail(S, "a candle closes before it opens");
1425        }
1426        if c.timeframe != tf || c.instrument.key != id.key {
1427            fail(
1428                S,
1429                format!(
1430                    "candle for {:?} {}, asked {tf:?} {}",
1431                    c.timeframe,
1432                    c.instrument.key.display(),
1433                    id.key.display()
1434                ),
1435            );
1436        }
1437    }
1438
1439    let st = md
1440        .trading_status(id)
1441        .await
1442        .unwrap_or_else(|e| fail(S, format!("trading_status failed: {e:?}")));
1443    if st.instrument.key != id.key {
1444        fail(S, "trading_status is about another instrument");
1445    }
1446    if st.availability == TradingAvailability::Unknown {
1447        fail(S, "the mock status must be a known one");
1448    }
1449}
1450
1451/// Market trades, quotes and candles as streams, each for the right instrument and tagged with our source and subscription id.
1452///
1453/// For each of the three kinds the scenario subscribes, then waits (up to [`EVENT_TIMEOUT`]) for an event
1454/// of that kind on that subscription, and checks:
1455/// - trades: source, instrument, positive price and quantity;
1456/// - quotes: source, instrument, at least a bid or an ask, and not crossed (bid not above ask);
1457/// - candles: source, instrument, the asked `candle_tf`, `low` not above `high`.
1458///
1459/// Trades use [`brokapi_core::SubscribeOpts::lossless`] (64), quotes and candles use
1460/// [`brokapi_core::SubscribeOpts::conflate`]. The subscriptions are left open. The mock must push a trade, a quote and
1461/// a candle after each subscribe. `rx` must be the receiver of the connector under test.
1462///
1463/// # Panics
1464///
1465/// With `[conformance:market_data_streams] <reason>` when a subscribe fails, an event does not arrive in
1466/// time, or a check fails.
1467///
1468/// # Examples
1469///
1470/// ```
1471/// # use brokapi_bcs::limits::default_limits;
1472/// # use brokapi_bcs::{MarketDataConfig, MarketDataConnector};
1473/// # use brokapi_core::{Timeframe, InstrumentId, InstrumentKey, NativeId, ShutdownMode};
1474/// # use brokapi_runtime::BrokerLimiter;
1475/// # #[tokio::main(flavor = "current_thread")]
1476/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1477/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
1478/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
1479/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
1480/// # let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "bcs-md"), BrokerLimiter::new(&default_limits()), tx).await?;
1481/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
1482/// brokapi_conformance::market_data_streams(&md, &mut rx, "bcs-md", &sber, Timeframe::M1).await;
1483/// # md.shutdown(ShutdownMode::Immediate).await;
1484/// # Ok(())
1485/// # }
1486/// ```
1487pub async fn market_data_streams<M: MarketDataSource + ?Sized>(
1488    md: &M,
1489    rx: &mut mpsc::Receiver<Envelope>,
1490    source: &str,
1491    id: &InstrumentId,
1492    candle_tf: Timeframe,
1493) {
1494    const S: &str = "market_data_streams";
1495    let t = md
1496        .subscribe_market_trades(id, SubscribeOpts::lossless(64))
1497        .await
1498        .unwrap_or_else(|e| fail(S, format!("subscribe_market_trades failed: {e:?}")));
1499    let env = next_matching(S, rx, |e| {
1500        matches!(e.payload, Event::MarketTrade(_)) && e.sub == Some(t)
1501    })
1502    .await;
1503    let Event::MarketTrade(tr) = &env.payload else {
1504        unreachable!()
1505    };
1506    if env.source.as_ref() != source {
1507        fail(S, format!("trade source {:?} != {source:?}", env.source));
1508    }
1509    if tr.instrument.key != id.key || !(tr.price.is_positive() && tr.qty.is_positive()) {
1510        fail(S, format!("bad market trade: {tr:?}"));
1511    }
1512
1513    let q = md
1514        .subscribe_quotes(id, SubscribeOpts::conflate())
1515        .await
1516        .unwrap_or_else(|e| fail(S, format!("subscribe_quotes failed: {e:?}")));
1517    let env = next_matching(S, rx, |e| {
1518        matches!(e.payload, Event::Quote(_)) && e.sub == Some(q)
1519    })
1520    .await;
1521    let Event::Quote(quote) = &env.payload else {
1522        unreachable!()
1523    };
1524    if env.source.as_ref() != source || quote.instrument.key != id.key {
1525        fail(S, format!("bad quote envelope: {env:?}"));
1526    }
1527    if quote.bid.is_none() && quote.ask.is_none() {
1528        fail(S, "a quote with neither bid nor ask");
1529    }
1530    if let (Some(b), Some(a)) = (&quote.bid, &quote.ask)
1531        && b.price > a.price
1532    {
1533        fail(S, "a quote is crossed: bid above ask");
1534    }
1535
1536    let c = md
1537        .subscribe_candles(id, candle_tf, SubscribeOpts::conflate())
1538        .await
1539        .unwrap_or_else(|e| fail(S, format!("subscribe_candles failed: {e:?}")));
1540    let env = next_matching(S, rx, |e| {
1541        matches!(e.payload, Event::Candle(_)) && e.sub == Some(c)
1542    })
1543    .await;
1544    let Event::Candle(candle) = &env.payload else {
1545        unreachable!()
1546    };
1547    if env.source.as_ref() != source
1548        || candle.instrument.key != id.key
1549        || candle.timeframe != candle_tf
1550    {
1551        fail(S, format!("bad candle envelope: {env:?}"));
1552    }
1553    if candle.low > candle.high {
1554        fail(S, "a candle with low above high");
1555    }
1556}
1557
1558// ─── own orders: events, active list, replace, executions ─────────────────────
1559
1560/// Places a limit buy. A local rate-limit refusal (nothing was sent) is waited out and retried under a
1561/// fresh cid, because a connector never sends the same cid twice. Returns the cid that went through.
1562async fn place_limit<U: OrderExecutor + ?Sized>(
1563    ud: &U,
1564    scenario: &str,
1565    id: &InstrumentId,
1566    price: Price,
1567    qty: Qty,
1568) -> ClientOrderId {
1569    for _ in 0..20 {
1570        let req = PlaceOrderRequest {
1571            cid: ClientOrderId::new_v4(),
1572            instrument: id.clone(),
1573            side: Side::Buy,
1574            order_type: OrderType::Limit,
1575            qty,
1576            price: Some(price),
1577        };
1578        let cid = req.cid;
1579        match ud.place(req).await {
1580            Err(Error::RateLimited { retry_after }) => tokio::time::sleep(retry_after).await,
1581            Ok(_) => return cid,
1582            Err(e) => fail(scenario, format!("place failed: {e:?}")),
1583        }
1584    }
1585    fail(scenario, "place still rate limited after 20 waits")
1586}
1587
1588/// Cancels with the same patience as [`place_with_cid`].
1589async fn cancel_patient<U: OrderExecutor + ?Sized>(ud: &U, scenario: &str, cid: ClientOrderId) {
1590    for _ in 0..20 {
1591        match ud.cancel(cid).await {
1592            Err(Error::RateLimited { retry_after }) => tokio::time::sleep(retry_after).await,
1593            Ok(()) => return,
1594            Err(e) => fail(scenario, format!("cancel failed: {e:?}")),
1595        }
1596    }
1597    fail(scenario, "cancel still rate limited after 20 waits");
1598}
1599
1600/// The order stream: placing an order gives an event for our cid, cancelling it gives a `Cancelled` event.
1601///
1602/// Steps and checks:
1603/// 1. `subscribe_order_state`.
1604/// 2. Place a buy limit order, one lot, at `price`, under a fresh cid. A local rate-limit refusal is
1605///    waited out and retried under a fresh cid (a cid is never sent twice).
1606/// 3. An [`brokapi_core::Event::Order`] for our cid arrives. Its source is `source`, its `sub` is the
1607///    subscription from step 1, and its state is not `Rejected`.
1608/// 4. Cancel the order. An order event with state `Cancelled` for the same cid arrives on the same
1609///    subscription.
1610///
1611/// Each wait is limited to [`EVENT_TIMEOUT`]. `price` must be far from the market and accepted by the
1612/// mock. The command gate must be open, so call [`reconcile`] first. The mock must push an order event
1613/// when an order is placed and when it is cancelled. If the adapter opens its order event socket in the
1614/// background, wait until it is connected before running this scenario, otherwise the first event can
1615/// be missed.
1616///
1617/// # Panics
1618///
1619/// With `[conformance:order_state_events] <reason>` when a call fails, an event does not arrive, or a
1620/// check fails.
1621///
1622/// # Examples
1623///
1624/// ```
1625/// # use brokapi_core::{Price};
1626/// # use brokapi_bcs::limits::default_limits;
1627/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
1628/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
1629/// # use brokapi_runtime::BrokerLimiter;
1630/// # #[tokio::main(flavor = "current_thread")]
1631/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1632/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
1633/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
1634/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
1635/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
1636/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
1637/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
1638/// brokapi_conformance::reconcile(&ud).await;
1639/// brokapi_conformance::order_state_events(&ud, &mut rx, "bcs-ud", &sber, Price::from(100)).await;
1640/// # ud.shutdown(ShutdownMode::Immediate).await;
1641/// # Ok(())
1642/// # }
1643/// ```
1644pub async fn order_state_events<U: OrderExecutor + ?Sized>(
1645    ud: &U,
1646    rx: &mut mpsc::Receiver<Envelope>,
1647    source: &str,
1648    id: &InstrumentId,
1649    price: Price,
1650) {
1651    const S: &str = "order_state_events";
1652    let sub = ud
1653        .subscribe_order_state()
1654        .await
1655        .unwrap_or_else(|e| fail(S, format!("subscribe_order_state failed: {e:?}")));
1656    let cid = place_limit(ud, S, id, price, lots(1)).await;
1657    let env = next_matching(
1658        S,
1659        rx,
1660        |e| matches!(&e.payload, Event::Order(o) if o.cid == cid),
1661    )
1662    .await;
1663    if env.source.as_ref() != source || env.sub != Some(sub) {
1664        fail(
1665            S,
1666            format!(
1667                "order event envelope: source {:?} sub {:?}, expected {source:?} / {sub:?}",
1668                env.source, env.sub
1669            ),
1670        );
1671    }
1672    let Event::Order(o) = &env.payload else {
1673        unreachable!()
1674    };
1675    if matches!(o.state, OrderState::Rejected { .. }) {
1676        fail(
1677            S,
1678            format!("a placed order came back rejected: {:?}", o.state),
1679        );
1680    }
1681    cancel_patient(ud, S, cid).await;
1682    let env = next_matching(S, rx, |e| {
1683        matches!(&e.payload, Event::Order(o) if o.cid == cid && matches!(o.state, OrderState::Cancelled { .. }))
1684    })
1685    .await;
1686    if env.sub != Some(sub) {
1687        fail(S, "the cancel event is not on the order subscription");
1688    }
1689}
1690
1691/// `active_orders` follows the order: a placed order is listed with its details, a cancelled one is gone.
1692///
1693/// Steps and checks:
1694/// 1. Place a buy limit order, three lots, at `price` (rate-limit refusals are waited out and retried
1695///    under a fresh cid).
1696/// 2. `active_orders()` lists the order under its cid, with the same instrument, side `Buy`, three lots
1697///    and price `price`, and a state that is not `Cancelled`, `Filled` or `Rejected`.
1698/// 3. Cancel it. `active_orders()` no longer lists it.
1699///
1700/// `price` must be far from the market and accepted by the mock. The command gate must be open, so
1701/// call [`reconcile`] first. The mock must keep placed orders in its active list.
1702///
1703/// # Panics
1704///
1705/// With `[conformance:active_orders_lifecycle] <reason>` when a call fails or a check fails.
1706///
1707/// # Examples
1708///
1709/// ```
1710/// # use brokapi_core::{Price};
1711/// # use brokapi_bcs::limits::default_limits;
1712/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
1713/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
1714/// # use brokapi_runtime::BrokerLimiter;
1715/// # #[tokio::main(flavor = "current_thread")]
1716/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1717/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
1718/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
1719/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
1720/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
1721/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
1722/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
1723/// brokapi_conformance::reconcile(&ud).await;
1724/// brokapi_conformance::active_orders_lifecycle(&ud, &sber, Price::from(100)).await;
1725/// # ud.shutdown(ShutdownMode::Immediate).await;
1726/// # Ok(())
1727/// # }
1728/// ```
1729pub async fn active_orders_lifecycle<U: OrderExecutor + ?Sized>(
1730    ud: &U,
1731    id: &InstrumentId,
1732    price: Price,
1733) {
1734    const S: &str = "active_orders_lifecycle";
1735    let cid = place_limit(ud, S, id, price, lots(3)).await;
1736    let active = ud
1737        .active_orders()
1738        .await
1739        .unwrap_or_else(|e| fail(S, format!("active_orders failed: {e:?}")));
1740    let Some(o) = active.iter().find(|o| o.cid == cid) else {
1741        fail(
1742            S,
1743            format!("the placed order {cid} is not in the active list: {active:?}"),
1744        );
1745    };
1746    if o.instrument.key != id.key || o.side != Side::Buy {
1747        fail(
1748            S,
1749            format!(
1750                "instrument/side lost: {} {:?}",
1751                o.instrument.key.display(),
1752                o.side
1753            ),
1754        );
1755    }
1756    if o.qty != lots(3) {
1757        fail(S, format!("quantity lost: {}", o.qty));
1758    }
1759    if o.price != Some(price) {
1760        fail(S, format!("price lost: {:?}", o.price));
1761    }
1762    if matches!(
1763        o.state,
1764        OrderState::Cancelled { .. } | OrderState::Filled { .. } | OrderState::Rejected { .. }
1765    ) {
1766        fail(S, format!("an active order in state {:?}", o.state));
1767    }
1768    cancel_patient(ud, S, cid).await;
1769    let after = ud
1770        .active_orders()
1771        .await
1772        .unwrap_or_else(|e| fail(S, format!("active_orders after cancel failed: {e:?}")));
1773    if after.iter().any(|o| o.cid == cid) {
1774        fail(S, "the cancelled order is still in the active list");
1775    }
1776}
1777
1778/// `replace`: where the broker has it the order moves to a new cid, where it has not, `replace` says `Unsupported`.
1779///
1780/// `supports_replace` is what the adapter reports in its capabilities. The scenario places a buy
1781/// limit order (two lots, at `price`) and then replaces it with a new cid, four lots and price `new_price`.
1782///
1783/// With `supports_replace == true`:
1784/// - the ack carries the new cid;
1785/// - `active_orders()` lists the new cid with four lots and price `new_price`, and no longer lists the
1786///   old one;
1787/// - `order_state(new cid)` is not `Rejected` or `Cancelled`.
1788///
1789/// With `supports_replace == false`:
1790/// - `replace` returns [`brokapi_core::Error::Unsupported`];
1791/// - the original order is still active. It is then cancelled.
1792///
1793/// Rate-limit refusals are waited out and retried. Both prices must be far from the market and accepted
1794/// by the mock. The command gate must be open, so call [`reconcile`] first.
1795///
1796/// # Panics
1797///
1798/// With `[conformance:replace_order] <reason>` when a check fails. Passing the wrong `supports_replace`
1799/// for the adapter fails the scenario: `true` for an adapter without replace gives a replace error,
1800/// `false` for one with replace gives "replace must be Unsupported where the broker has none".
1801///
1802/// # Examples
1803///
1804/// ```
1805/// # use brokapi_core::{Price};
1806/// # use brokapi_bcs::limits::default_limits;
1807/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
1808/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
1809/// # use brokapi_runtime::BrokerLimiter;
1810/// # #[tokio::main(flavor = "current_thread")]
1811/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1812/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
1813/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
1814/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
1815/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
1816/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
1817/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
1818/// brokapi_conformance::reconcile(&ud).await;
1819/// assert!(ud.caps().supports_replace);
1820/// brokapi_conformance::replace_order(&ud, true, &sber, Price::from(100), Price::from(101)).await;
1821/// # ud.shutdown(ShutdownMode::Immediate).await;
1822/// # Ok(())
1823/// # }
1824/// ```
1825///
1826/// Claiming that BCS has no replace fails the scenario:
1827///
1828/// ```should_panic(expected = "[conformance:replace_order] replace must be Unsupported where the broker has none")
1829/// # use brokapi_core::{Price};
1830/// # use brokapi_bcs::limits::default_limits;
1831/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
1832/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
1833/// # use brokapi_runtime::BrokerLimiter;
1834/// # #[tokio::main(flavor = "current_thread")]
1835/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1836/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
1837/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
1838/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
1839/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
1840/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
1841/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
1842/// brokapi_conformance::reconcile(&ud).await;
1843/// // panics: [conformance:replace_order] replace must be Unsupported where the broker has none, got ...
1844/// brokapi_conformance::replace_order(&ud, false, &sber, Price::from(100), Price::from(101)).await;
1845/// # ud.shutdown(ShutdownMode::Immediate).await;
1846/// # Ok(())
1847/// # }
1848/// ```
1849pub async fn replace_order<U: OrderExecutor + ?Sized>(
1850    ud: &U,
1851    supports_replace: bool,
1852    id: &InstrumentId,
1853    price: Price,
1854    new_price: Price,
1855) {
1856    const S: &str = "replace_order";
1857    let cid = place_limit(ud, S, id, price, lots(2)).await;
1858    let new = ClientOrderId::new_v4();
1859    let req = ReplaceOrderRequest {
1860        cid: new,
1861        original_cid: cid,
1862        qty: Some(lots(4)),
1863        price: Some(new_price),
1864    };
1865    if !supports_replace {
1866        match ud.replace(req).await {
1867            Err(Error::Unsupported { .. }) => {}
1868            other => fail(
1869                S,
1870                format!("replace must be Unsupported where the broker has none, got {other:?}"),
1871            ),
1872        }
1873        if !ud
1874            .active_orders()
1875            .await
1876            .unwrap_or_default()
1877            .iter()
1878            .any(|o| o.cid == cid)
1879        {
1880            fail(S, "an unsupported replace must leave the order alone");
1881        }
1882        cancel_patient(ud, S, cid).await;
1883        return;
1884    }
1885    let mut ack = None;
1886    for _ in 0..20 {
1887        match ud.replace(req.clone()).await {
1888            Err(Error::RateLimited { retry_after }) => tokio::time::sleep(retry_after).await,
1889            other => {
1890                ack = Some(other);
1891                break;
1892            }
1893        }
1894    }
1895    let ack = ack
1896        .unwrap_or_else(|| fail(S, "replace still rate limited"))
1897        .unwrap_or_else(|e| fail(S, format!("replace failed: {e:?}")));
1898    if ack.cid != new {
1899        fail(S, format!("ack cid {} != new cid {new}", ack.cid));
1900    }
1901    let active = ud
1902        .active_orders()
1903        .await
1904        .unwrap_or_else(|e| fail(S, format!("active_orders failed: {e:?}")));
1905    let Some(o) = active.iter().find(|o| o.cid == new) else {
1906        fail(S, format!("the new order {new} is not active: {active:?}"));
1907    };
1908    if o.qty != lots(4) || o.price != Some(new_price) {
1909        fail(
1910            S,
1911            format!("new quantity/price lost: {} {:?}", o.qty, o.price),
1912        );
1913    }
1914    if active.iter().any(|o| o.cid == cid) {
1915        fail(S, "the replaced order is still active");
1916    }
1917    let state = ud
1918        .order_state(new)
1919        .await
1920        .unwrap_or_else(|e| fail(S, format!("order_state of the new cid failed: {e:?}")));
1921    if matches!(
1922        state.state,
1923        OrderState::Rejected { .. } | OrderState::Cancelled { .. }
1924    ) {
1925        fail(S, format!("the new order is {:?}", state.state));
1926    }
1927    cancel_patient(ud, S, new).await;
1928}
1929
1930/// Own executions: the history works, and a fill arrives as an `Execution` event and in the history.
1931///
1932/// Steps and checks:
1933/// 1. `executions(range)` over a very wide range succeeds on an empty account.
1934/// 2. `subscribe_executions`.
1935/// 3. Place a buy limit order, two lots, at `price` (rate-limit refusals are waited out and retried
1936///    under a fresh cid), then call `fill(cid)`.
1937/// 4. An [`brokapi_core::Event::Execution`] arrives. Its source is `source`, its `sub` is the executions
1938///    subscription, its cid is ours, price and quantity are positive, the trade id is not empty and the
1939///    instrument is `id`.
1940/// 5. `executions(range)` now lists at least one trade, each with a positive price, positive quantity
1941///    and a trade id.
1942///
1943/// `fill(cid)` makes the mock execute the order. Each mock has its own way, so the adapter provides it.
1944/// It may wait until the stream is open. It runs once. `price` must be far from the market and accepted
1945/// by the mock, and the fill itself is the mock's business. The command gate must be open, so call
1946/// [`reconcile`] first, and wait for the adapter's event socket if it connects in the background.
1947///
1948/// # Panics
1949///
1950/// With `[conformance:executions] <reason>` when a call fails, no execution arrives in time, or a check
1951/// fails.
1952///
1953/// # Examples
1954///
1955/// ```
1956/// # use brokapi_core::{Price};
1957/// # use brokapi_bcs::limits::default_limits;
1958/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
1959/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, ShutdownMode};
1960/// # use brokapi_runtime::BrokerLimiter;
1961/// # #[tokio::main(flavor = "current_thread")]
1962/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1963/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
1964/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
1965/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
1966/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
1967/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
1968/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
1969/// brokapi_conformance::reconcile(&ud).await;
1970/// brokapi_conformance::executions(&ud, &mut rx, "bcs-ud", &sber, Price::from(100), |cid| {
1971///     // The BCS mock executes an order when told: cid, quantity, price.
1972///     let _ = server.fill(&cid.hyphenated().to_string(), brokapi_core::Qty::from(20), brokapi_core::Price::from(100));
1973///     async {}
1974/// })
1975/// .await;
1976/// # ud.shutdown(ShutdownMode::Immediate).await;
1977/// # Ok(())
1978/// # }
1979/// ```
1980/// # use brokapi_core::{Qty};
1981pub async fn executions<U, F, Fut>(
1982    ud: &U,
1983    rx: &mut mpsc::Receiver<Envelope>,
1984    source: &str,
1985    id: &InstrumentId,
1986    price: Price,
1987    fill: F,
1988) where
1989    U: OrderExecutor + ExecutionSource + ?Sized,
1990    F: FnOnce(ClientOrderId) -> Fut,
1991    Fut: Future<Output = ()>,
1992{
1993    const S: &str = "executions";
1994    let range = TimeRange::new(UnixNanos::ZERO, UnixNanos::from_nanos(i64::MAX / 2));
1995    ud.executions(range).await.unwrap_or_else(|e| {
1996        fail(
1997            S,
1998            format!("executions(range) failed on an empty account: {e:?}"),
1999        )
2000    });
2001    let sub = ud
2002        .subscribe_executions()
2003        .await
2004        .unwrap_or_else(|e| fail(S, format!("subscribe_executions failed: {e:?}")));
2005    let cid = place_limit(ud, S, id, price, lots(2)).await;
2006    fill(cid).await;
2007    let env = next_matching(S, rx, |e| matches!(e.payload, Event::Execution(_))).await;
2008    if env.source.as_ref() != source || env.sub != Some(sub) {
2009        fail(
2010            S,
2011            format!(
2012                "execution envelope: source {:?} sub {:?}, expected {source:?} / {sub:?}",
2013                env.source, env.sub
2014            ),
2015        );
2016    }
2017    let Event::Execution(x) = &env.payload else {
2018        unreachable!()
2019    };
2020    if x.cid != Some(cid) {
2021        fail(
2022            S,
2023            format!("the execution is for {:?}, we placed {cid}", x.cid),
2024        );
2025    }
2026    if !(x.price.is_positive() && x.qty.is_positive()) || x.trade_id.is_empty() {
2027        fail(S, format!("bad execution: {x:?}"));
2028    }
2029    if x.instrument.key != id.key {
2030        fail(S, "the execution is for another instrument");
2031    }
2032    let hist = ud
2033        .executions(range)
2034        .await
2035        .unwrap_or_else(|e| fail(S, format!("executions(range) after a fill failed: {e:?}")));
2036    if hist.is_empty() {
2037        fail(S, "the history is empty after a fill");
2038    }
2039    if hist
2040        .iter()
2041        .any(|h| !(h.price.is_positive() && h.qty.is_positive()) || h.trade_id.is_empty())
2042    {
2043        fail(
2044            S,
2045            format!("a history entry without price, quantity or trade id: {hist:?}"),
2046        );
2047    }
2048}
2049
2050// ─── a subscription is made when the stream is open ───────────────────────────
2051
2052/// The future of one `subscribe_*` call, as the scenarios of this section hand it to the adapter.
2053pub type SubscribeCall<'a> =
2054    std::pin::Pin<Box<dyn Future<Output = Result<SubscriptionId, Error>> + 'a>>;
2055
2056/// `subscribe_order_state` and `subscribe_executions` return when the stream is open, not before.
2057///
2058/// A bot places an order right after `subscribe_*` returns. If the call returned before the broker had opened
2059/// the stream, an order that is filled in that window would never be reported: the event happened while nobody
2060/// was listening. So a call returns `Ok` only when the stream is live, and a stream that does not open in time
2061/// is an error, with nothing left half open.
2062///
2063/// The adapter holds the opening of the streams at the broker before the call (the mock's `stream_gate`; for a
2064/// connector that opens its sockets at start, before the connector is started). Then:
2065/// 1. `drive` runs the `subscribe_order_state` future to its end while the broker holds the stream and the clock
2066///    moves past the connector's wait (`brokapi_mocks::time::elapse_when`). The outcome is
2067///    [`Error::Transport`], not `Ok`: the stream is not open.
2068/// 2. The same for `subscribe_executions`.
2069/// 3. `release` lets the broker open the streams.
2070/// 4. Both calls are repeated and succeed: the failed calls left nothing behind that blocks them.
2071/// 5. An order is placed and then filled by `fill`: the `Order` event arrives on the order state subscription,
2072///    and the `Execution` event on the executions subscription. Both came after the calls returned.
2073///
2074/// The command gate must be open, so call [`reconcile`] first. The order is bought far from the market, two lots.
2075///
2076/// # Panics
2077///
2078/// With `[conformance:subscribe_waits_for_the_stream] <reason>`.
2079///
2080/// # Examples
2081///
2082/// ```
2083/// # use std::time::Duration;
2084/// # use brokapi_bcs::limits::default_limits;
2085/// # use brokapi_bcs::{UserDataConfig, UserDataConnector};
2086/// # use brokapi_core::{InstrumentId, InstrumentKey, NativeId, Price, Qty, ShutdownMode};
2087/// # use brokapi_runtime::BrokerLimiter;
2088/// # #[tokio::main(flavor = "current_thread")]
2089/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2090/// # let opts = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
2091/// # let server = brokapi_mocks::bcs::MockServer::start(std::sync::Arc::clone(&opts)).await;
2092/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
2093/// # let (tx, mut rx) = tokio::sync::mpsc::channel(256);
2094/// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() });
2095/// // The broker is slow to open its sockets: held before the connector starts, as BCS opens them at start.
2096/// opts.stream_gate.hold();
2097/// # let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
2098/// brokapi_conformance::reconcile(&ud).await;
2099/// brokapi_conformance::subscribe_waits_for_the_stream(
2100///     &ud,
2101///     &mut rx,
2102///     "bcs-ud",
2103///     &sber,
2104///     Price::from(100),
2105///     |call| {
2106///         brokapi_mocks::time::elapse_when(
2107///             "the socket request reached the broker",
2108///             || opts.stream_gate.waiting() > 0,
2109///             Duration::from_secs(10),
2110///             call,
2111///         )
2112///     },
2113///     || async { opts.stream_gate.release() },
2114///     |cid| {
2115///         let _ = server.fill(&cid.hyphenated().to_string(), Qty::from(20), Price::from(100));
2116///         async {}
2117///     },
2118/// )
2119/// .await;
2120/// # ud.shutdown(ShutdownMode::Immediate).await;
2121/// # Ok(())
2122/// # }
2123/// ```
2124#[allow(clippy::too_many_arguments)]
2125pub async fn subscribe_waits_for_the_stream<'a, U, D, DFut, R, RFut, F, FFut>(
2126    ud: &'a U,
2127    rx: &mut mpsc::Receiver<Envelope>,
2128    source: &str,
2129    id: &InstrumentId,
2130    price: Price,
2131    mut drive: D,
2132    release: R,
2133    fill: F,
2134) where
2135    U: OrderExecutor + ExecutionSource + ?Sized,
2136    D: FnMut(SubscribeCall<'a>) -> DFut,
2137    DFut: Future<Output = Result<SubscriptionId, Error>> + 'a,
2138    R: FnOnce() -> RFut,
2139    RFut: Future<Output = ()>,
2140    F: FnOnce(ClientOrderId) -> FFut,
2141    FFut: Future<Output = ()>,
2142{
2143    const S: &str = "subscribe_waits_for_the_stream";
2144    let orders: SubscribeCall<'a> = Box::pin(ud.subscribe_order_state());
2145    match drive(orders).await {
2146        Err(Error::Transport(_)) => {}
2147        other => fail(
2148            S,
2149            format!(
2150                "subscribe_order_state returned before the stream was open: expected Transport, got {other:?}"
2151            ),
2152        ),
2153    }
2154    let fills: SubscribeCall<'a> = Box::pin(ud.subscribe_executions());
2155    match drive(fills).await {
2156        Err(Error::Transport(_)) => {}
2157        other => fail(
2158            S,
2159            format!(
2160                "subscribe_executions returned before the stream was open: expected Transport, got {other:?}"
2161            ),
2162        ),
2163    }
2164    release().await;
2165    let orders_sub = ud.subscribe_order_state().await.unwrap_or_else(|e| {
2166        fail(
2167            S,
2168            format!("subscribe_order_state after the open failed: {e:?}"),
2169        )
2170    });
2171    let fills_sub = ud.subscribe_executions().await.unwrap_or_else(|e| {
2172        fail(
2173            S,
2174            format!("subscribe_executions after the open failed: {e:?}"),
2175        )
2176    });
2177    let cid = place_limit(ud, S, id, price, lots(2)).await;
2178    fill(cid).await;
2179    let env = next_matching(
2180        S,
2181        rx,
2182        |e| matches!(&e.payload, Event::Order(o) if o.cid == cid),
2183    )
2184    .await;
2185    if env.source.as_ref() != source || env.sub != Some(orders_sub) {
2186        fail(
2187            S,
2188            format!(
2189                "order event envelope: source {:?} sub {:?}, expected {source:?} / {orders_sub:?}",
2190                env.source, env.sub
2191            ),
2192        );
2193    }
2194    let env = next_matching(
2195        S,
2196        rx,
2197        |e| matches!(&e.payload, Event::Execution(x) if x.cid == Some(cid)),
2198    )
2199    .await;
2200    if env.sub != Some(fills_sub) {
2201        fail(
2202            S,
2203            format!(
2204                "the execution is on sub {:?}, expected {fills_sub:?}",
2205                env.sub
2206            ),
2207        );
2208    }
2209}
2210
2211/// `subscribe_order_book` returns when the stream is open, and a stream that does not open is an error.
2212///
2213/// The adapter holds the opening of the stream at the broker (the mock's `stream_gate`, or `md_silent` at
2214/// T-Bank). Then:
2215/// 1. `drive` runs the `subscribe_order_book` future to its end while the broker holds the stream and the clock
2216///    moves past the connector's wait. The outcome is [`Error::Transport`], not `Ok`.
2217/// 2. `release` lets the broker open the stream.
2218/// 3. The call is repeated and succeeds, and a book arrives on the new subscription: the failed call left
2219///    nothing behind that blocks it.
2220///
2221/// # Panics
2222///
2223/// With `[conformance:market_subscribe_waits_for_the_stream] <reason>`.
2224pub async fn market_subscribe_waits_for_the_stream<'a, M, D, DFut, R, RFut>(
2225    md: &'a M,
2226    rx: &mut mpsc::Receiver<Envelope>,
2227    id: &'a InstrumentId,
2228    mut drive: D,
2229    release: R,
2230) where
2231    M: OrderBookSource + ?Sized,
2232    D: FnMut(SubscribeCall<'a>) -> DFut,
2233    DFut: Future<Output = Result<SubscriptionId, Error>> + 'a,
2234    R: FnOnce() -> RFut,
2235    RFut: Future<Output = ()>,
2236{
2237    const S: &str = "market_subscribe_waits_for_the_stream";
2238    let call: SubscribeCall<'a> = Box::pin(md.subscribe_order_book(id, SubscribeOpts::conflate()));
2239    match drive(call).await {
2240        Err(Error::Transport(_)) => {}
2241        other => fail(
2242            S,
2243            format!(
2244                "subscribe_order_book returned before the stream was open: expected Transport, got {other:?}"
2245            ),
2246        ),
2247    }
2248    release().await;
2249    let sub = md
2250        .subscribe_order_book(id, SubscribeOpts::conflate())
2251        .await
2252        .unwrap_or_else(|e| {
2253            fail(
2254                S,
2255                format!("subscribe_order_book after the open failed: {e:?}"),
2256            )
2257        });
2258    next_matching(S, rx, |e| {
2259        matches!(e.payload, Event::OrderBook(_)) && e.sub == Some(sub)
2260    })
2261    .await;
2262}
2263
2264/// The soft variant of [`subscribe_waits_for_the_stream`], for a broker whose stream may announce itself only with
2265/// its first message (Finam, gRPC): `subscribe_*` waits for the stream, and when it does not open in time it still
2266/// returns `Ok` and the stream goes on opening in the background.
2267///
2268/// The adapter holds the opening of the streams at the broker before the call. Then:
2269/// 1. `drive` runs the `subscribe_order_state` future to its end while the broker holds the stream and the clock
2270///    moves past the connector's wait (but the stream open attempt must not time out itself). The outcome is `Ok`, not
2271///    an error: the subscription stays.
2272/// 2. The same for `subscribe_executions`.
2273/// 3. `release` lets the broker open the streams, and `opened` returns when the broker has them open (the test
2274///    waits for the counter of the mock, not for time).
2275/// 4. An order is placed and then filled by `fill`: the `Order` event arrives on the subscription returned in
2276///    step 1, and the `Execution` event on the one returned in step 2.
2277///
2278/// # Panics
2279///
2280/// With `[conformance:subscribe_returns_while_the_stream_opens_late] <reason>`.
2281#[allow(clippy::too_many_arguments)]
2282pub async fn subscribe_returns_while_the_stream_opens_late<
2283    'a,
2284    U,
2285    D,
2286    DFut,
2287    R,
2288    RFut,
2289    O,
2290    OFut,
2291    F,
2292    FFut,
2293>(
2294    ud: &'a U,
2295    rx: &mut mpsc::Receiver<Envelope>,
2296    source: &str,
2297    id: &InstrumentId,
2298    price: Price,
2299    mut drive: D,
2300    release: R,
2301    opened: O,
2302    fill: F,
2303) where
2304    U: OrderExecutor + ExecutionSource + ?Sized,
2305    D: FnMut(SubscribeCall<'a>) -> DFut,
2306    DFut: Future<Output = Result<SubscriptionId, Error>> + 'a,
2307    R: FnOnce() -> RFut,
2308    RFut: Future<Output = ()>,
2309    O: FnOnce() -> OFut,
2310    OFut: Future<Output = ()>,
2311    F: FnOnce(ClientOrderId) -> FFut,
2312    FFut: Future<Output = ()>,
2313{
2314    const S: &str = "subscribe_returns_while_the_stream_opens_late";
2315    let orders: SubscribeCall<'a> = Box::pin(ud.subscribe_order_state());
2316    let orders_sub = drive(orders).await.unwrap_or_else(|e| {
2317        fail(
2318            S,
2319            format!("subscribe_order_state must return Ok while the stream opens, got {e:?}"),
2320        )
2321    });
2322    let fills: SubscribeCall<'a> = Box::pin(ud.subscribe_executions());
2323    let fills_sub = drive(fills).await.unwrap_or_else(|e| {
2324        fail(
2325            S,
2326            format!("subscribe_executions must return Ok while the stream opens, got {e:?}"),
2327        )
2328    });
2329    release().await;
2330    opened().await;
2331    let cid = place_limit(ud, S, id, price, lots(2)).await;
2332    fill(cid).await;
2333    let env = next_matching(
2334        S,
2335        rx,
2336        |e| matches!(&e.payload, Event::Order(o) if o.cid == cid),
2337    )
2338    .await;
2339    if env.source.as_ref() != source || env.sub != Some(orders_sub) {
2340        fail(
2341            S,
2342            format!(
2343                "order event envelope: source {:?} sub {:?}, expected {source:?} / {orders_sub:?}",
2344                env.source, env.sub
2345            ),
2346        );
2347    }
2348    let env = next_matching(
2349        S,
2350        rx,
2351        |e| matches!(&e.payload, Event::Execution(x) if x.cid == Some(cid)),
2352    )
2353    .await;
2354    if env.sub != Some(fills_sub) {
2355        fail(
2356            S,
2357            format!(
2358                "the execution is on sub {:?}, expected {fills_sub:?}",
2359                env.sub
2360            ),
2361        );
2362    }
2363}
2364
2365/// The soft variant of [`market_subscribe_waits_for_the_stream`] (see
2366/// [`subscribe_returns_while_the_stream_opens_late`]): `subscribe_order_book` returns `Ok` when the stream is not
2367/// open in time, and after `release` the books arrive on that subscription.
2368///
2369/// # Panics
2370///
2371/// With `[conformance:market_subscribe_returns_while_the_stream_opens_late] <reason>`.
2372pub async fn market_subscribe_returns_while_the_stream_opens_late<'a, M, D, DFut, R, RFut>(
2373    md: &'a M,
2374    rx: &mut mpsc::Receiver<Envelope>,
2375    id: &'a InstrumentId,
2376    mut drive: D,
2377    release: R,
2378) where
2379    M: OrderBookSource + ?Sized,
2380    D: FnMut(SubscribeCall<'a>) -> DFut,
2381    DFut: Future<Output = Result<SubscriptionId, Error>> + 'a,
2382    R: FnOnce() -> RFut,
2383    RFut: Future<Output = ()>,
2384{
2385    const S: &str = "market_subscribe_returns_while_the_stream_opens_late";
2386    let call: SubscribeCall<'a> = Box::pin(md.subscribe_order_book(id, SubscribeOpts::conflate()));
2387    let sub = drive(call).await.unwrap_or_else(|e| {
2388        fail(
2389            S,
2390            format!("subscribe_order_book must return Ok while the stream opens, got {e:?}"),
2391        )
2392    });
2393    release().await;
2394    next_matching(S, rx, |e| {
2395        matches!(e.payload, Event::OrderBook(_)) && e.sub == Some(sub)
2396    })
2397    .await;
2398}
2399
2400// ─── guarantees of the command path ───────────────────────────────────────────
2401
2402fn order(id: &InstrumentId, price: Price, qty: Qty) -> PlaceOrderRequest {
2403    PlaceOrderRequest {
2404        cid: ClientOrderId::new_v4(),
2405        instrument: id.clone(),
2406        side: Side::Buy,
2407        order_type: OrderType::Limit,
2408        qty,
2409        price: Some(price),
2410    }
2411}
2412
2413/// How many of the active orders are ours: the cid is among `cids`.
2414async fn active_among<U: OrderExecutor + ?Sized>(
2415    ud: &U,
2416    scenario: &str,
2417    cids: &[ClientOrderId],
2418) -> usize {
2419    ud.active_orders()
2420        .await
2421        .unwrap_or_else(|e| fail(scenario, format!("active_orders failed: {e:?}")))
2422        .iter()
2423        .filter(|o| cids.contains(&o.cid))
2424        .count()
2425}
2426
2427/// A `cid` is used for one place at most: the second place with it is refused locally and creates nothing.
2428///
2429/// Steps and checks:
2430/// 1. `place` a buy limit order of one lot at `price` under a fresh cid. It succeeds.
2431/// 2. `place` with the same cid again: [`Error::Rejected`] with code `duplicate_place`, not `Ok` and not
2432///    `Unknown`. Nothing was sent, so the broker holds one order of that cid.
2433/// 3. Cancel the order.
2434///
2435/// The command gate must be open, so call [`reconcile`] first.
2436///
2437/// # Panics
2438///
2439/// With `[conformance:duplicate_place] <reason>`.
2440pub async fn duplicate_place<U: OrderExecutor + ?Sized>(ud: &U, id: &InstrumentId, price: Price) {
2441    const S: &str = "duplicate_place";
2442    let req = order(id, price, lots(1));
2443    let cid = req.cid;
2444    ud.place(req.clone())
2445        .await
2446        .unwrap_or_else(|e| fail(S, format!("the first place failed: {e:?}")));
2447    match ud.place(req).await {
2448        Err(Error::Rejected { code, .. }) if code == "duplicate_place" => {}
2449        other => fail(
2450            S,
2451            format!("the same cid twice must be Rejected(duplicate_place), got {other:?}"),
2452        ),
2453    }
2454    let live = active_among(ud, S, &[cid]).await;
2455    if live != 1 {
2456        fail(
2457            S,
2458            format!("the broker must hold one order of the cid, holds {live}"),
2459        );
2460    }
2461    cancel_patient(ud, S, cid).await;
2462}
2463
2464/// A quantity the broker cannot take is refused before anything is sent, and the cid stays usable.
2465///
2466/// The refused quantities, one after another with the same cid: zero, a negative number, a fraction of
2467/// a unit (`0.5`), and a number of units that is not a whole number of lots (`LOT + 5`). Each must be
2468/// [`Error::Rejected`] with code `invalid_qty`: never rounded into an order, never `Unknown`. Then the same
2469/// cid with one lot goes through, which shows the refusals used no cid, and the order is cancelled.
2470///
2471/// The connector must know that the lot of `id` is [`LOT`]: the T-Bank connector looks it up itself, for
2472/// BCS and Finam the test tells it with `price_grid().set_lot(..)`.
2473///
2474/// # Panics
2475///
2476/// With `[conformance:invalid_qty] <reason>`.
2477pub async fn invalid_qty<U: OrderExecutor + ?Sized>(ud: &U, id: &InstrumentId, price: Price) {
2478    const S: &str = "invalid_qty";
2479    let mut req = order(id, price, lots(1));
2480    let cid = req.cid;
2481    let bad = [
2482        ("zero", Qty::ZERO),
2483        ("negative", Qty::from(-LOT)),
2484        ("a fraction", Qty::new(Decimal::new(5, 1))),
2485        ("not a whole number of lots", Qty::from(LOT + 5)),
2486    ];
2487    for (what, qty) in bad {
2488        req.qty = qty;
2489        match ud.place(req.clone()).await {
2490            Err(Error::Rejected { code, .. }) if code == "invalid_qty" => {}
2491            other => fail(
2492                S,
2493                format!("a quantity that is {what} must be Rejected(invalid_qty), got {other:?}"),
2494            ),
2495        }
2496    }
2497    req.qty = lots(1);
2498    ud.place(req).await.unwrap_or_else(|e| {
2499        fail(
2500            S,
2501            format!("the refusals must not use the cid, but place failed: {e:?}"),
2502        )
2503    });
2504    cancel_patient(ud, S, cid).await;
2505}
2506
2507/// An instrument id of another broker's kind is a configuration error, and nothing is sent.
2508///
2509/// `foreign` is an [`InstrumentId`] whose `NativeId` is of a kind this broker does not use. A `place` with
2510/// it must be [`Error::Config`]: not `Rejected` (that would say the broker was asked), not `Unknown`.
2511///
2512/// # Panics
2513///
2514/// With `[conformance:foreign_instrument_id] <reason>`.
2515pub async fn foreign_instrument_id<U: OrderExecutor + ?Sized>(
2516    ud: &U,
2517    foreign: &InstrumentId,
2518    price: Price,
2519) {
2520    const S: &str = "foreign_instrument_id";
2521    match ud.place(order(foreign, price, lots(1))).await {
2522        Err(Error::Config(_)) => {}
2523        other => fail(
2524            S,
2525            format!("an id of another broker must be a Config error, got {other:?}"),
2526        ),
2527    }
2528}
2529
2530/// A place the broker never answers is `Unknown`: not repeated, not `Rejected`, resolved by asking.
2531///
2532/// `drive` receives the future of the `place` and must run it to its end while making the connector's
2533/// request time out (the test holds the answer at the mock and moves the clock past the timeout of the
2534/// connector). Then:
2535/// 1. The outcome is [`Error::Unknown`].
2536/// 2. The same cid again is `Rejected(duplicate_place)`: the library never repeats a place.
2537/// 3. `release` lets the broker finish: the mock took the order after all. `order_state(cid)` then finds
2538///    it (retried until it does), and the order is cancelled.
2539///
2540/// # Panics
2541///
2542/// With `[conformance:place_without_answer_is_unknown] <reason>`.
2543pub async fn place_without_answer_is_unknown<'a, U, D, DFut, R, RFut>(
2544    ud: &'a U,
2545    id: &InstrumentId,
2546    price: Price,
2547    drive: D,
2548    release: R,
2549) where
2550    U: OrderExecutor + ?Sized,
2551    D: FnOnce(
2552        std::pin::Pin<Box<dyn Future<Output = Result<brokapi_core::OrderAck, Error>> + 'a>>,
2553    ) -> DFut,
2554    DFut: Future<Output = Result<brokapi_core::OrderAck, Error>> + 'a,
2555    R: FnOnce() -> RFut,
2556    RFut: Future<Output = ()>,
2557{
2558    const S: &str = "place_without_answer_is_unknown";
2559    let req = order(id, price, lots(1));
2560    let cid = req.cid;
2561    let call: std::pin::Pin<Box<dyn Future<Output = Result<brokapi_core::OrderAck, Error>> + 'a>> =
2562        Box::pin(ud.place(req.clone()));
2563    match drive(call).await {
2564        Err(Error::Unknown) => {}
2565        other => fail(
2566            S,
2567            format!("a place without an answer must be Unknown, got {other:?}"),
2568        ),
2569    }
2570    match ud.place(req).await {
2571        Err(Error::Rejected { code, .. }) if code == "duplicate_place" => {}
2572        other => fail(
2573            S,
2574            format!("a place is never repeated: expected Rejected(duplicate_place), got {other:?}"),
2575        ),
2576    }
2577    release().await;
2578    let mut found = false;
2579    for _ in 0..50 {
2580        match ud.order_state(cid).await {
2581            Ok(_) => {
2582                found = true;
2583                break;
2584            }
2585            Err(e) if e.is_not_found() => tokio::task::yield_now().await,
2586            Err(Error::RateLimited { retry_after }) => tokio::time::sleep(retry_after).await,
2587            Err(e) => fail(S, format!("order_state failed: {e:?}")),
2588        }
2589    }
2590    if !found {
2591        fail(S, "the order the broker took is not found by order_state");
2592    }
2593    cancel_patient(ud, S, cid).await;
2594}
2595
2596/// The stream of own orders breaks: the bot is told, the gate closes, `reconcile` opens it.
2597///
2598/// `cut` breaks the order and fill streams from the broker side, and returns once the broker has done it
2599/// (the connector then reopens the streams by itself). `reopened` says when the streams are open again.
2600/// Steps and checks:
2601/// 1. `subscribe_order_state` and `subscribe_executions`; `reopened` is awaited once for the first open.
2602/// 2. `cut()`. An [`Event::Reconnected`] arrives for the order state subscription, and one for the
2603///    executions subscription.
2604/// 3. `place` is [`Error::Degraded`] and sends nothing (`active_orders` shows no order of ours).
2605/// 4. `reconcile(&[])` succeeds and opens the gate. A place goes through and is cancelled.
2606///
2607/// # Panics
2608///
2609/// With `[conformance:stream_cut_degrades_until_reconcile] <reason>`.
2610pub async fn stream_cut_degrades_until_reconcile<U, C, CFut>(
2611    ud: &U,
2612    rx: &mut mpsc::Receiver<Envelope>,
2613    id: &InstrumentId,
2614    price: Price,
2615    cut: C,
2616) where
2617    U: OrderExecutor + ExecutionSource + ?Sized,
2618    C: FnOnce() -> CFut,
2619    CFut: Future<Output = ()>,
2620{
2621    const S: &str = "stream_cut_degrades_until_reconcile";
2622    let orders_sub = ud
2623        .subscribe_order_state()
2624        .await
2625        .unwrap_or_else(|e| fail(S, format!("subscribe_order_state failed: {e:?}")));
2626    let fills_sub = ud
2627        .subscribe_executions()
2628        .await
2629        .unwrap_or_else(|e| fail(S, format!("subscribe_executions failed: {e:?}")));
2630    cut().await;
2631    // One notice for each of the two subscriptions, in whatever order they come.
2632    let (mut orders_told, mut fills_told) = (false, false);
2633    while !(orders_told && fills_told) {
2634        let env = next_matching(S, rx, |e| matches!(e.payload, Event::Reconnected { .. })).await;
2635        let Event::Reconnected { sub, .. } = env.payload else {
2636            unreachable!()
2637        };
2638        if env.sub != Some(sub) {
2639            fail(
2640                S,
2641                format!("the Reconnected of {sub:?} carries sub {:?}", env.sub),
2642            );
2643        }
2644        if sub == orders_sub {
2645            orders_told = true;
2646        } else if sub == fills_sub {
2647            fills_told = true;
2648        } else {
2649            fail(
2650                S,
2651                format!("Reconnected for a subscription nobody made: {sub:?}"),
2652            );
2653        }
2654    }
2655    let req = order(id, price, lots(1));
2656    let cid = req.cid;
2657    match ud.place(req).await {
2658        Err(Error::Degraded) => {}
2659        other => fail(
2660            S,
2661            format!("after the cut a place must be Degraded, got {other:?}"),
2662        ),
2663    }
2664    if active_among(ud, S, &[cid]).await != 0 {
2665        fail(S, "the refused place reached the broker");
2666    }
2667    ud.reconcile(&[])
2668        .await
2669        .unwrap_or_else(|e| fail(S, format!("reconcile after the cut failed: {e:?}")));
2670    let cid = place_limit(ud, S, id, price, lots(1)).await;
2671    cancel_patient(ud, S, cid).await;
2672}
2673
2674/// A command that waits in the queue of its instrument is checked against the gate again when its turn
2675/// comes: a gate that closed meanwhile stops it, and nothing is sent.
2676///
2677/// Two places on one instrument go out together. The first is held at the broker (`in_flight` returns when
2678/// the broker has it in hand), the second waits in the queue of the instrument. Then `degrade` closes the
2679/// gate (it breaks the order streams) and the test waits for the `Reconnected` event, and `release` lets
2680/// the broker finish the first one. Checks:
2681/// - the first place is `Ok` or `Unknown` (it was sent before the gate closed);
2682/// - the second is [`Error::Degraded`]: it did not go out after the queue;
2683/// - after `reconcile(&[])`, of the two cids only the first is at the broker. Both are cancelled.
2684///
2685/// # Panics
2686///
2687/// With `[conformance:gate_checked_after_the_queue] <reason>`.
2688pub async fn gate_checked_after_the_queue<U, A, AFut, D, DFut, R, RFut>(
2689    ud: &U,
2690    rx: &mut mpsc::Receiver<Envelope>,
2691    id: &InstrumentId,
2692    price: Price,
2693    in_flight: A,
2694    degrade: D,
2695    release: R,
2696) where
2697    U: OrderExecutor + ?Sized,
2698    A: FnOnce() -> AFut,
2699    AFut: Future<Output = ()>,
2700    D: FnOnce() -> DFut,
2701    DFut: Future<Output = ()>,
2702    R: FnOnce() -> RFut,
2703    RFut: Future<Output = ()>,
2704{
2705    const S: &str = "gate_checked_after_the_queue";
2706    let orders_sub = ud
2707        .subscribe_order_state()
2708        .await
2709        .unwrap_or_else(|e| fail(S, format!("subscribe_order_state failed: {e:?}")));
2710    let (first, second) = (order(id, price, lots(1)), order(id, price, lots(1)));
2711    let (c1, c2) = (first.cid, second.cid);
2712    let driver = async {
2713        in_flight().await;
2714        degrade().await;
2715        next_matching(
2716            S,
2717            rx,
2718            |e| matches!(e.payload, Event::Reconnected { sub, .. } if sub == orders_sub),
2719        )
2720        .await;
2721        release().await;
2722    };
2723    let (r1, r2, ()) = tokio::join!(ud.place(first), ud.place(second), driver);
2724    if !matches!(r1, Ok(_) | Err(Error::Unknown)) {
2725        fail(
2726            S,
2727            format!("the first place was sent before the gate closed, got {r1:?}"),
2728        );
2729    }
2730    if !matches!(r2, Err(Error::Degraded)) {
2731        fail(S, format!("the queued place must be Degraded, got {r2:?}"));
2732    }
2733    ud.reconcile(&[c1])
2734        .await
2735        .unwrap_or_else(|e| fail(S, format!("reconcile failed: {e:?}")));
2736    let mut at_broker = 0;
2737    for _ in 0..50 {
2738        at_broker = active_among(ud, S, &[c1, c2]).await;
2739        if at_broker >= 1 {
2740            break;
2741        }
2742        tokio::task::yield_now().await;
2743    }
2744    if at_broker != 1 {
2745        fail(
2746            S,
2747            format!("only the first order may be at the broker, found {at_broker}"),
2748        );
2749    }
2750    cancel_patient(ud, S, c1).await;
2751}
2752
2753/// The order book stream is void after a break: a [`Event::Resync`] follows, then a fresh snapshot.
2754///
2755/// `prepare` sets the mock so that it breaks the stream after the first snapshot (it runs before the
2756/// subscribe). Steps and checks, on one subscription:
2757/// 1. A [`Event::Resync`] for the subscription and the instrument arrives (books before it may or may not).
2758/// 2. A book arrives after the `Resync`: the stream came back.
2759///
2760/// # Panics
2761///
2762/// With `[conformance:order_book_resync] <reason>`.
2763pub async fn order_book_resync<M: OrderBookSource + ?Sized>(
2764    md: &M,
2765    rx: &mut mpsc::Receiver<Envelope>,
2766    id: &InstrumentId,
2767    prepare: impl FnOnce(),
2768) {
2769    const S: &str = "order_book_resync";
2770    prepare();
2771    let sub = md
2772        .subscribe_order_book(id, SubscribeOpts::lossless(64))
2773        .await
2774        .unwrap_or_else(|e| fail(S, format!("subscribe_order_book failed: {e:?}")));
2775    // The cut may come before or after the first book: wait for the Resync of the subscription.
2776    let env = next_matching(
2777        S,
2778        rx,
2779        |e| matches!(e.payload, Event::Resync { sub: s, .. } if s == sub),
2780    )
2781    .await;
2782    let Event::Resync { instrument, .. } = &env.payload else {
2783        unreachable!()
2784    };
2785    if instrument.key != id.key {
2786        fail(
2787            S,
2788            format!("Resync for {:?}, expected {:?}", instrument.key, id.key),
2789        );
2790    }
2791    // After it the stream is back: a book arrives.
2792    next_matching(S, rx, |e| {
2793        e.sub == Some(sub) && matches!(e.payload, Event::OrderBook(_))
2794    })
2795    .await;
2796}
2797
2798/// A read of an instrument the broker does not know is `Rejected(not_found)`, the same at every broker.
2799///
2800/// Not `Transport` (that would invite a retry), not `Ok`, not a differently spelled code.
2801///
2802/// # Panics
2803///
2804/// With `[conformance:unknown_instrument] <reason>`.
2805pub async fn unknown_instrument<M: OrderBookSource + ?Sized>(md: &M, unknown: &InstrumentId) {
2806    const S: &str = "unknown_instrument";
2807    match md.order_book(unknown, 5).await {
2808        Err(e) if e.is_not_found() => {}
2809        other => fail(
2810            S,
2811            format!("an unknown instrument must be Rejected(not_found), got {other:?}"),
2812        ),
2813    }
2814}
2815
2816/// The history of own trades is complete: `expected` trades come back, none is dropped or doubled.
2817///
2818/// The test has put `expected` trades in the mock (more than one page of the broker's list). Checks:
2819/// `executions(all time)` returns exactly `expected` entries, with `expected` different trade ids.
2820///
2821/// # Panics
2822///
2823/// With `[conformance:executions_history_is_complete] <reason>`.
2824pub async fn executions_history_is_complete<U: ExecutionSource + ?Sized>(ud: &U, expected: usize) {
2825    const S: &str = "executions_history_is_complete";
2826    let range = TimeRange::new(UnixNanos::ZERO, UnixNanos::from_nanos(i64::MAX / 2));
2827    let got = ud
2828        .executions(range)
2829        .await
2830        .unwrap_or_else(|e| fail(S, format!("executions failed: {e:?}")));
2831    let mut ids: Vec<&str> = got.iter().map(|x| x.trade_id.as_str()).collect();
2832    ids.sort_unstable();
2833    ids.dedup();
2834    if got.len() != expected || ids.len() != expected {
2835        fail(
2836            S,
2837            format!(
2838                "expected {expected} different trades, got {} entries with {} different ids",
2839                got.len(),
2840                ids.len()
2841            ),
2842        );
2843    }
2844}