1mod 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
36pub struct Trading {
70 service: Option<Arc<dyn Broker>>,
71 inbox: Arc<Inbox>,
72 listener: Option<Listener>,
73 generation: u64,
74 connection: Option<FeedStatus>,
77 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 rules_failed: bool,
90 placing: Vec<(u64, Position, OrderRequest)>,
92 ticket: OrderTicket,
93 pending: Option<TradingLineAction>,
95 preview: Option<Preview>,
97 decimals: usize,
99 instruments: HashMap<String, Instrument>,
102 chart_logo: Option<(String, Option<String>)>,
104 formatter: Option<Arc<dyn TradingFormatter>>,
105 quote: Option<(String, Quote)>,
107 pub quantity: f64,
111 pub presets: Vec<OrderPreset>,
113 pub account_panel: AccountPanel,
115 pub display: TradingSettings,
120 sound: Option<String>,
122 calculator: Option<(egui::Pos2, String)>,
124}
125
126struct 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 pub fn set_quote(&mut self, symbol: &str, quote: Quote) {
175 self.quote = Some((symbol.to_owned(), quote));
176 }
177
178 pub fn set_instrument(&mut self, instrument: Instrument) {
185 self.instruments
186 .insert(instrument.symbol.clone(), instrument);
187 }
188
189 pub fn set_formatter(&mut self, formatter: Option<Arc<dyn TradingFormatter>>) {
193 self.formatter = formatter;
194 }
195
196 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 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 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 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 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 pub fn has_broker(&self) -> bool {
255 self.service.is_some()
256 }
257
258 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 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 self.inbox.reply(self.generation).send(Ok(()));
294 }
295 }
296
297 pub fn capabilities(&self) -> Capabilities {
299 self.account
300 .as_ref()
301 .map(|account| account.capabilities)
302 .unwrap_or_default()
303 }
304
305 pub fn open_ticket(&mut self, request: OrderRequest) {
307 self.load_rules(&request.symbol);
308 self.ticket.open(&request);
309 }
310
311 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 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 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 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 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 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
409fn 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 #[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 #[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}