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)) = ("e.bid, "e.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}