Skip to main content

egui_charts/ui/trading/
mod.rs

1//! Trading from the chart through the host's [`Broker`]: the broker picker, the order ticket,
2//! the Buy and Sell buttons, depth ladder and the account panel. The broker holds the account;
3//! this module shows what it pushes and sends the orders the user confirms.
4
5mod account_panel;
6mod broker_picker;
7mod dom;
8mod events;
9mod executions;
10mod format;
11mod ticket;
12mod view;
13mod widgets;
14
15pub use account_panel::{AccountPanel, AccountPanelAction};
16pub use broker_picker::{BrokerFact, BrokerPicker, BrokerPickerAction, BrokerTile};
17pub use format::TradingFormatter;
18pub use ticket::{Bracket, BracketMode, OrderPreset};
19
20use std::collections::HashMap;
21use std::sync::Arc;
22
23use crate::chart::TradingLineAction;
24use crate::data::inbox::Inbox;
25use crate::data::{FeedError, FeedStatus};
26use crate::model::{
27    Account, Capabilities, Instrument, Order, OrderPreview, OrderRequest, OrderSide, OrderType,
28    Position, Quote, TradingRules,
29};
30use crate::services::{Broker, HistoryKind, Listener};
31use crate::ui::model::ConnectionState;
32use crate::widget::settings::TradingSettings;
33use format::Numbers;
34use ticket::{OrderTicket, order_quantity};
35
36/// The trading controls of one chart, connected to the host's [`Broker`].
37///
38/// Call [`poll`](Self::poll) every frame, [`show_ticket`](Self::show_ticket) before laying out
39/// the chart, so the order ticket takes its panel beside it, and [`show`](Self::show) after the
40/// chart. Open the order ticket with [`open_ticket`](Self::open_ticket), for example from the
41/// chart menu's [`Order`](crate::ui::context_menu::ContextMenuAction::Order) entries, and route
42/// the chart's Buy and Sell buttons ([`Chart::take_quote_action`](crate::widget::Chart::take_quote_action)) to [`trade`](Self::trade).
43/// Orders reach the broker only from the ticket's submit button, or once the user confirms them
44/// in the depth ladder, or at once from Buy and Sell and the ladder when the user turned on
45/// [`TradingSettings::instant_orders`].
46///
47/// ```no_run
48/// # fn frame(ui: &mut egui::Ui, chart: &mut egui_charts::Chart,
49/// #     trading: &mut egui_charts::ui::trading::Trading,
50/// #     toasts: &mut egui_charts::ui_kit::notifications::Toasts) {
51/// use egui_charts::model::OrderSide;
52/// use egui_charts::widget::QuoteSide;
53///
54/// trading.poll(ui.ctx(), toasts);
55/// trading.show_ticket(ui, chart);
56/// chart.show(ui);
57/// if let Some(side) = chart.take_quote_action() {
58///     if side == QuoteSide::Ticket {
59///         trading.open_ticket(egui_charts::model::OrderRequest::new(chart.symbol(),
60///             OrderSide::Buy, trading.quantity, egui_charts::model::OrderType::Market));
61///     } else {
62///         let side = if side == QuoteSide::Buy { OrderSide::Buy } else { OrderSide::Sell };
63///         trading.trade(chart.symbol().to_owned(), side);
64///     }
65/// }
66/// trading.show(ui, chart);
67/// # }
68/// ```
69pub struct Trading {
70    service: Option<Arc<dyn Broker>>,
71    inbox: Arc<Inbox>,
72    listener: Option<Listener>,
73    generation: u64,
74    /// The connection status the broker last pushed on the current stream; `None`, which counts
75    /// as connected, until it pushes one.
76    connection: Option<FeedStatus>,
77    /// Set while the pushes the broker made during [`Broker::connect`] wait in the inbox: they
78    /// restate the account and raise no notification.
79    restating: bool,
80    account: Option<Account>,
81    orders: Vec<Order>,
82    positions: Vec<Position>,
83    rules: TradingRules,
84    rules_key: u64,
85    request_key: u64,
86    rules_ready: bool,
87    /// Whether the rules of `rules.symbol` failed for another reason than the connection, or
88    /// came back invalid: they are not asked for again when the broker is live again.
89    rules_failed: bool,
90    /// Confirmed position orders waiting for their symbol's trading rules.
91    placing: Vec<(u64, Position, OrderRequest)>,
92    ticket: OrderTicket,
93    /// A change the user asked for on a line, waiting for confirmation.
94    pending: Option<TradingLineAction>,
95    /// An order the user asked the broker to preview, until it is placed or discarded.
96    preview: Option<Preview>,
97    /// Decimals of the chart's prices, for notifications.
98    decimals: usize,
99    /// The metadata of the symbols the host described with
100    /// [`set_instrument`](Self::set_instrument), by symbol.
101    instruments: HashMap<String, Instrument>,
102    /// The chart symbol and its logo at the last [`show`](Self::show), for rows and notices.
103    chart_logo: Option<(String, Option<String>)>,
104    formatter: Option<Arc<dyn TradingFormatter>>,
105    /// The latest quote of a symbol, from [`set_quote`](Self::set_quote).
106    quote: Option<(String, Quote)>,
107    /// The quantity the ticket opens with, and that instant orders and the chart menu's orders
108    /// use. Positive, in the symbol's trading units. The chart's legend shows it under the quote
109    /// buttons' spread, where a click opens a calculator that changes it.
110    pub quantity: f64,
111    /// The quantity and brackets the user saved in the ticket. Persist them in the host.
112    pub presets: Vec<OrderPreset>,
113    /// The account panel's names and the history it lists.
114    pub account_panel: AccountPanel,
115    /// The chart settings' trading options: what [`show`](Self::show) draws, whether Buy and
116    /// Sell trade at once, which events raise notifications and whether fills ask for a sound.
117    /// Copy the chart settings' [`trading`](crate::widget::settings::ChartSettingsState::trading)
118    /// options here when the user applies them.
119    pub display: TradingSettings,
120    /// The sound asked for by fills since the host last took it.
121    sound: Option<String>,
122    /// The open quantity calculator: where it sits and the text of its field.
123    calculator: Option<(egui::Pos2, String)>,
124}
125
126/// An order sent to [`Broker::preview`] and the estimate it was answered with. Confirming the
127/// preview places `request` itself, whatever the ticket shows by then.
128struct Preview {
129    key: u64,
130    request: OrderRequest,
131    estimate: Option<OrderPreview>,
132}
133
134impl Default for Trading {
135    fn default() -> Self {
136        Self {
137            service: None,
138            inbox: Arc::default(),
139            listener: None,
140            generation: 0,
141            connection: None,
142            restating: false,
143            account: None,
144            orders: Vec::new(),
145            positions: Vec::new(),
146            rules: TradingRules::new(""),
147            rules_key: 1,
148            request_key: 1,
149            rules_ready: false,
150            rules_failed: false,
151            placing: Vec::new(),
152            ticket: OrderTicket::default(),
153            pending: None,
154            preview: None,
155            decimals: 2,
156            instruments: HashMap::new(),
157            chart_logo: None,
158            formatter: None,
159            quote: None,
160            quantity: 1.0,
161            presets: Vec::new(),
162            account_panel: AccountPanel::default(),
163            display: TradingSettings::default(),
164            sound: None,
165            calculator: None,
166        }
167    }
168}
169
170impl Trading {
171    /// Sets the latest quote of `symbol`, such as the one `WatchlistPanel::quote` holds for the
172    /// chart's symbol. While the chart shows `symbol`, the ticket enters a market buy at the
173    /// quote's ask and a market sell at its bid; without them, at the last price.
174    pub fn set_quote(&mut self, symbol: &str, quote: Quote) {
175        self.quote = Some((symbol.to_owned(), quote));
176    }
177
178    /// Describes `instrument` to the trading panels: its price decimals write the prices of its
179    /// symbol in the account tables, and its pip size gives the ticket a pip value when the
180    /// broker's [`TradingRules::pip_value`] is absent. The chart's own instrument is used for the
181    /// symbol it shows, so call this for the other symbols of the account, such as those of
182    /// positions and working orders. Its logo marks the symbol in the account tables and notices.
183    /// A symbol without an instrument shows two decimals.
184    pub fn set_instrument(&mut self, instrument: Instrument) {
185        self.instruments
186            .insert(instrument.symbol.clone(), instrument);
187    }
188
189    /// Writes the prices, quantities, spreads and pip values of the ticket, the depth ladder and
190    /// the account tables with `formatter`, where it returns text. `None` restores the built-in
191    /// text.
192    pub fn set_formatter(&mut self, formatter: Option<Arc<dyn TradingFormatter>>) {
193        self.formatter = formatter;
194    }
195
196    /// The logo of `symbol`: the chart instrument's for the chart symbol, else that of the
197    /// instrument the host described.
198    fn logo(&self, symbol: &str) -> Option<&str> {
199        match &self.chart_logo {
200            Some((chart, logo)) if chart == symbol => logo.as_deref(),
201            _ => self.instruments.get(symbol)?.logo.as_deref(),
202        }
203    }
204
205    /// How the numbers of `symbol` are written, with the decimals of the instrument the host
206    /// described, or of the chart's prices for `chart_symbol`.
207    fn numbers<'a>(&'a self, symbol: &'a str, chart_symbol: &str) -> Numbers<'a> {
208        let decimals = if symbol == chart_symbol {
209            self.decimals
210        } else {
211            (self.instruments.get(symbol))
212                .and_then(crate::scales::instrument_price_precision)
213                .map_or(2, usize::from)
214        };
215        Numbers {
216            symbol,
217            decimals,
218            formatter: self.formatter.as_deref(),
219        }
220    }
221
222    /// Connects to `service`, dropping the previous account's state.
223    pub fn set_service(&mut self, service: Arc<dyn Broker>) {
224        self.listener = None;
225        self.service = Some(service);
226        self.inbox = Arc::default();
227        self.ticket.open = false;
228        self.connect();
229    }
230
231    /// Disconnects from the broker, dropping the account's state.
232    pub fn disconnect(&mut self) {
233        self.listener = None;
234        self.service = None;
235        self.inbox = Arc::default();
236        self.display.instant_orders = false;
237        self.ticket.open = false;
238        self.connect();
239    }
240
241    /// Whether a broker is set and connected: the last status it pushed on the current stream is
242    /// [`FeedStatus::Live`], or it pushed none. False while it reports that it is
243    /// [`Connecting`](FeedStatus::Connecting) or [`Disconnected`](FeedStatus::Disconnected), and
244    /// without a broker; [`has_broker`](Self::has_broker) tells whether one is set.
245    pub fn is_connected(&self) -> bool {
246        self.service.is_some()
247            && self
248                .connection
249                .is_none_or(|status| status == FeedStatus::Live)
250    }
251
252    /// Whether a broker is set, from [`set_service`](Self::set_service) until
253    /// [`disconnect`](Self::disconnect), connected or not.
254    pub fn has_broker(&self) -> bool {
255        self.service.is_some()
256    }
257
258    /// The connection status the account panel and the order ticket show while the broker
259    /// reports that it is not live; `None` while it is live, before it reports a status and
260    /// without a broker.
261    fn offline(&self) -> Option<ConnectionState> {
262        match self.connection {
263            Some(FeedStatus::Connecting) => Some(ConnectionState::Connecting),
264            Some(FeedStatus::Disconnected) => Some(ConnectionState::Disconnected),
265            Some(FeedStatus::Live) | None => None,
266        }
267    }
268
269    fn connect(&mut self) {
270        self.pending = None;
271        self.preview = None;
272        self.sound = None;
273        self.calculator = None;
274        self.ticket = OrderTicket::default();
275        self.rules = TradingRules::new("");
276        self.rules_ready = false;
277        self.rules_failed = false;
278        self.account = None;
279        self.connection = None;
280        self.orders.clear();
281        self.positions.clear();
282        self.placing.clear();
283        self.account_panel.clear();
284        // Stream keys are even; rules, previews and history pages use odd keys, and order
285        // replies use zero.
286        self.generation += 2;
287        let (listener, events) = Listener::new(&self.inbox, self.generation);
288        self.listener = Some(listener);
289        self.restating = self.service.is_some();
290        if let Some(service) = &self.service {
291            service.connect(events);
292            // Queued after what the broker pushed while connecting, this ends the restated state.
293            self.inbox.reply(self.generation).send(Ok(()));
294        }
295    }
296
297    /// The order types and features the account accepts; none before the broker reports them.
298    pub fn capabilities(&self) -> Capabilities {
299        self.account
300            .as_ref()
301            .map(|account| account.capabilities)
302            .unwrap_or_default()
303    }
304
305    /// Opens the order ticket filled from `request`, for the user to check and submit.
306    pub fn open_ticket(&mut self, request: OrderRequest) {
307        self.load_rules(&request.symbol);
308        self.ticket.open(&request);
309    }
310
311    /// Opens the order ticket for `symbol`, or closes it when it is open, as the toolbar's Trade
312    /// button does.
313    pub fn toggle_ticket(&mut self, symbol: String) {
314        if self.ticket.open {
315            self.ticket.open = false;
316        } else {
317            self.open_ticket(OrderRequest::new(
318                symbol,
319                OrderSide::Buy,
320                self.quantity,
321                OrderType::Market,
322            ));
323        }
324    }
325
326    /// Handles the Buy or Sell button: a market order of [`quantity`](Self::quantity) at once
327    /// with [`TradingSettings::instant_orders`], otherwise the ticket for one.
328    pub fn trade(&mut self, symbol: String, side: OrderSide) {
329        self.load_rules(&symbol);
330        let mut request = OrderRequest::new(symbol, side, self.quantity, OrderType::Market);
331        if self.display.instant_orders
332            && self.capabilities().market
333            && self.rules_ready
334            && let Ok(quantity) = order_quantity(self.quantity, &self.rules)
335        {
336            self.ticket.open = false;
337            request.quantity = quantity;
338            request.time_in_force = self.rules.time_in_force[0];
339            self.place(request);
340        } else {
341            self.open_ticket(request);
342        }
343    }
344
345    fn place(&self, request: OrderRequest) {
346        if let Some(service) = self.live_service() {
347            service.place(request, self.inbox.reply(0));
348        }
349    }
350
351    /// The broker to send an order, cancellation or change to: `None` without a broker, and
352    /// while the broker reports that it is not live, when nothing is sent and the next
353    /// [`poll`](Self::poll) reports the order, cancellation or change as refused.
354    fn live_service(&self) -> Option<Arc<dyn Broker>> {
355        let service = self.service.clone()?;
356        if self.is_connected() {
357            return Some(service);
358        }
359        self.refuse();
360        None
361    }
362
363    /// Reports an order, cancellation or change as refused because the broker is not live, the
364    /// way [`poll`](Self::poll) reports the broker's own refusals.
365    fn refuse(&self) {
366        let error = FeedError::Other("the broker is not connected".into());
367        self.inbox.reply::<()>(0).send(Err(error));
368    }
369
370    fn load_rules(&mut self, symbol: &str) {
371        if self.rules.symbol != symbol {
372            self.pending = None;
373            self.rules = TradingRules::new(symbol);
374            self.rules_ready = false;
375            self.rules_failed = false;
376            self.ask_rules();
377        }
378    }
379
380    /// Asks the broker for the rules of `rules.symbol` under a new key, so that the answer to an
381    /// earlier request is dropped; while the broker is not live, the request waits until it is.
382    fn ask_rules(&mut self) {
383        self.request_key += 2;
384        self.rules_key = self.request_key;
385        if self.is_connected()
386            && let Some(service) = &self.service
387        {
388            service.rules(&self.rules.symbol, self.inbox.reply(self.rules_key));
389        }
390    }
391
392    /// Asks the broker for the page of `kind` before the oldest entry the account panel lists,
393    /// once the account arrived and while the broker is live; see [`AccountPanel`].
394    fn load_older(&mut self, kind: HistoryKind) {
395        let Some(service) = &self.service else {
396            return;
397        };
398        if self.account.is_none() || !self.is_connected() {
399            return;
400        }
401        let key = self.request_key + 2;
402        if let Some(request) = self.account_panel.ask(kind, key) {
403            self.request_key = key;
404            service.history(request, self.inbox.reply(key));
405        }
406    }
407}
408
409/// Whether every number of `rules` is positive and finite and a time in force is offered.
410fn valid(rules: &TradingRules) -> bool {
411    let positive = |value: f64| value.is_finite() && value > 0.0;
412    positive(rules.quantity_step)
413        && positive(rules.min_quantity)
414        && positive(rules.point_value)
415        && rules
416            .max_quantity
417            .is_none_or(|max| positive(max) && max >= rules.min_quantity)
418        && rules.leverage.is_none_or(positive)
419        && rules.pip_value.is_none_or(positive)
420        && !rules.time_in_force.is_empty()
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::data::{FeedError, Reply};
427    use crate::model::OrderId;
428    use crate::services::{AccountEvent, Events, HistoryPage, HistoryRequest};
429    use crate::ui_kit::notifications::Toasts;
430    use std::sync::Mutex;
431
432    /// A broker that pushes `state` on connecting and records the calls it gets.
433    #[derive(Default)]
434    pub(super) struct Recorder {
435        pub(super) state: Vec<AccountEvent>,
436        pub(super) brackets: bool,
437        pub(super) placed: Mutex<Vec<OrderRequest>>,
438        pub(super) cancelled: Mutex<Vec<OrderId>>,
439        pub(super) modified: Mutex<Vec<(OrderId, OrderRequest)>>,
440        pub(super) rules: Mutex<Vec<(String, Reply<TradingRules>)>>,
441        pub(super) previews: Mutex<Vec<(OrderRequest, Reply<OrderPreview>)>>,
442        pub(super) histories: Mutex<Vec<(HistoryRequest, Reply<HistoryPage>)>>,
443    }
444
445    impl Broker for Recorder {
446        fn connect(&self, events: Events<AccountEvent>) {
447            let capabilities = Capabilities {
448                market: true,
449                limit: true,
450                stop: true,
451                modify: true,
452                close: true,
453                reverse: true,
454                preview: true,
455                brackets: self.brackets,
456                ..Capabilities::default()
457            };
458            events.push(AccountEvent::Account(Account::new(
459                "USD",
460                0.0,
461                capabilities,
462            )));
463            self.state
464                .iter()
465                .for_each(|event| events.push(event.clone()));
466        }
467        fn rules(&self, symbol: &str, reply: Reply<TradingRules>) {
468            self.rules.lock().unwrap().push((symbol.to_owned(), reply));
469        }
470        fn preview(&self, order: OrderRequest, reply: Reply<OrderPreview>) {
471            self.previews.lock().unwrap().push((order, reply));
472        }
473        fn modify(&self, id: OrderId, order: OrderRequest, reply: Reply<()>) {
474            self.modified.lock().unwrap().push((id, order));
475            reply.send(Ok(()));
476        }
477        fn place(&self, order: OrderRequest, reply: Reply<OrderId>) {
478            self.placed.lock().unwrap().push(order);
479            reply.send(Ok(OrderId::new("1")));
480        }
481        fn cancel(&self, id: OrderId, reply: Reply<()>) {
482            self.cancelled.lock().unwrap().push(id);
483            reply.send(Ok(()));
484        }
485        fn history(&self, request: HistoryRequest, reply: Reply<HistoryPage>) {
486            self.histories.lock().unwrap().push((request, reply));
487        }
488    }
489
490    /// Guards the money path of the Buy and Sell buttons: without instant orders they only
491    /// open the ticket and send nothing; with it they send exactly one market order of the
492    /// default quantity. A regression either trades without the user's confirmation or loses
493    /// the instant order.
494    #[test]
495    fn buy_and_sell_trade_at_once_only_with_instant_orders() {
496        let broker = Arc::new(Recorder::default());
497        let mut trading = Trading::default();
498        trading.set_service(broker.clone());
499        trading.poll(&egui::Context::default(), &mut Toasts::new());
500        trading.quantity = 3.0;
501
502        trading.trade("AAPL".into(), OrderSide::Sell);
503        assert!(broker.placed.lock().unwrap().is_empty());
504        assert!(trading.ticket.open);
505
506        trading.display.instant_orders = true;
507        trading.trade("AAPL".into(), OrderSide::Buy);
508        assert!(broker.placed.lock().unwrap().is_empty(), "wait for rules");
509        let mut rules = TradingRules::new("AAPL");
510        rules.quantity_step = 2.0;
511        rules.min_quantity = 2.0;
512        rules.max_quantity = Some(4.0);
513        rules.time_in_force = vec![crate::model::TimeInForce::GoodTillCancelled];
514        broker
515            .rules
516            .lock()
517            .unwrap()
518            .pop()
519            .unwrap()
520            .1
521            .send(Ok(rules));
522        trading.poll(&egui::Context::default(), &mut Toasts::new());
523        for (quantity, accepted) in [(3.0, true), (1.0, false), (6.0, false), (f64::NAN, false)] {
524            trading.quantity = quantity;
525            trading.trade("AAPL".into(), OrderSide::Buy);
526            let placed = std::mem::take(&mut *broker.placed.lock().unwrap());
527            if accepted {
528                let mut expected =
529                    OrderRequest::new("AAPL", OrderSide::Buy, 2.0, OrderType::Market);
530                expected.time_in_force = crate::model::TimeInForce::GoodTillCancelled;
531                assert_eq!(placed, [expected]);
532                assert!(!trading.ticket.open);
533            } else {
534                assert!(placed.is_empty());
535            }
536        }
537        trading.load_rules("ES");
538        trading.load_rules("NQ");
539        let mut replies = broker.rules.lock().unwrap();
540        let current = replies.pop().unwrap().1;
541        let stale = replies.pop().unwrap().1;
542        stale.send(Err(FeedError::NotSupported));
543        trading.poll(&egui::Context::default(), &mut Toasts::new());
544        assert!(
545            !trading.rules_ready,
546            "an old answer cannot enable the new symbol"
547        );
548        current.send(Err(FeedError::NotSupported));
549        trading.poll(&egui::Context::default(), &mut Toasts::new());
550        assert!(trading.rules_ready);
551    }
552
553    #[test]
554    fn position_actions_use_their_symbol_rules_before_placing() {
555        use crate::model::{PositionBracket, TimeInForce};
556        for operation in ["close", "reverse", "take profit", "stop loss"] {
557            for response in ["supported", "fallback", "failure", "invalid", "changed"] {
558                let broker = Arc::new(Recorder::default());
559                let mut trading = Trading::default();
560                trading.set_service(broker.clone());
561                let ctx = egui::Context::default();
562                trading.load_rules("CHART");
563                let (_, reply) = broker.rules.lock().unwrap().pop().unwrap();
564                reply.send(Ok(TradingRules::new("CHART")));
565                trading.poll(&ctx, &mut Toasts::new());
566                let position = Position::new("OTHER", 2.0, 100.0);
567                let mut expected = OrderRequest::new(
568                    "OTHER",
569                    OrderSide::Sell,
570                    if operation == "reverse" { 4.0 } else { 2.0 },
571                    match operation {
572                        "take profit" => OrderType::Limit(110.0),
573                        "stop loss" => OrderType::Stop(90.0),
574                        _ => OrderType::Market,
575                    },
576                );
577                expected.position_bracket = match operation {
578                    "take profit" => Some(PositionBracket::TakeProfit),
579                    "stop loss" => Some(PositionBracket::StopLoss),
580                    _ => None,
581                };
582                let action = match operation {
583                    "close" => TradingLineAction::Close(position),
584                    "reverse" => TradingLineAction::Reverse(position),
585                    _ => TradingLineAction::Exit(position, expected.clone()),
586                };
587                trading.execute(Some(action));
588                assert!(
589                    broker.placed.lock().unwrap().is_empty(),
590                    "wait for {operation} rules"
591                );
592                let (symbol, reply) = broker.rules.lock().unwrap().pop().unwrap();
593                assert_eq!(symbol, "OTHER");
594                let mut rules = TradingRules::new(symbol);
595                rules.time_in_force = vec![TimeInForce::GoodTillCancelled];
596                if response == "changed" {
597                    trading.apply(
598                        &egui::Context::default(),
599                        AccountEvent::Position(Position::new("OTHER", 1.0, 100.0)),
600                        &mut Toasts::new(),
601                    );
602                }
603                if response == "invalid" {
604                    rules.time_in_force.clear();
605                }
606                reply.send(match response {
607                    "fallback" => Err(FeedError::NotSupported),
608                    "failure" => Err(FeedError::Network("offline".into())),
609                    _ => Ok(rules),
610                });
611                trading.poll(&ctx, &mut Toasts::new());
612                let placed = std::mem::take(&mut *broker.placed.lock().unwrap());
613                if matches!(response, "supported" | "fallback") {
614                    if response == "supported" {
615                        expected.time_in_force = TimeInForce::GoodTillCancelled;
616                    }
617                    assert_eq!(placed, [expected]);
618                } else {
619                    assert!(placed.is_empty(), "{operation} with {response} rules");
620                }
621                assert_eq!(trading.rules.symbol, "CHART");
622                assert!(trading.rules_ready);
623            }
624        }
625    }
626}