Skip to main content

nautilus_binance/common/
execution.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Shared execution client utilities for Binance Spot and Futures adapters.
17
18use std::{
19    future::Future,
20    time::{Duration, Instant},
21};
22
23use nautilus_common::enums::LogLevel;
24use nautilus_live::{ExecutionClientCore, task::TaskGroup};
25use nautilus_model::{
26    events::OrderEventAny,
27    identifiers::AccountId,
28    orders::{Order, OrderAny},
29};
30
31pub(crate) fn active_orders(core: &ExecutionClientCore) -> Vec<OrderAny> {
32    let cache = core.cache();
33    cache
34        .orders_refs(Some(&core.venue), None, None, Some(&core.account_id), None)
35        .into_iter()
36        .filter(|order| {
37            (order.is_open() || order.is_inflight())
38                && !order.strategy_id().is_external()
39                && cache.client_id(&order.client_order_id()) == Some(&core.client_id)
40                && (order.ts_submitted().is_some()
41                    || order.events().iter().any(|event| {
42                        matches!(event, OrderEventAny::Accepted(accepted) if !accepted.reconciliation)
43                    }))
44        })
45        .map(|order| order.cloned())
46        .collect()
47}
48
49pub(crate) fn log_report_receipt(count: usize, report_type: &str, level: LogLevel) {
50    let level = match level {
51        LogLevel::Off => return,
52        LogLevel::Trace => log::Level::Trace,
53        LogLevel::Debug => log::Level::Debug,
54        LogLevel::Info => log::Level::Info,
55        LogLevel::Warning => log::Level::Warn,
56        LogLevel::Error => log::Level::Error,
57    };
58    let plural = if count == 1 { "" } else { "s" };
59    log::log!(level, "Received {count} {report_type}{plural}");
60}
61
62/// Spawns an async task and tracks its handle in `pending_tasks`.
63pub fn spawn_task<F>(pending_tasks: &TaskGroup, description: &'static str, fut: F)
64where
65    F: Future<Output = anyhow::Result<()>> + Send + 'static,
66{
67    let future = async move {
68        if let Err(e) = fut.await {
69            log::warn!("{description} failed: {e}");
70        }
71    };
72
73    if let Err(e) = pending_tasks.spawn(future) {
74        log::warn!("Skipping Binance {description} after shutdown began: {e}");
75    }
76}
77
78/// Aborts all pending tasks stored in `pending_tasks`.
79pub fn abort_pending_tasks(pending_tasks: &TaskGroup) {
80    pending_tasks.begin_shutdown();
81}
82
83/// Completes bounded shutdown for Binance command tasks.
84///
85/// # Errors
86///
87/// Returns an error if bounded task shutdown fails.
88pub async fn await_pending_tasks(pending_tasks: &TaskGroup) -> anyhow::Result<()> {
89    pending_tasks.begin_shutdown();
90    pending_tasks
91        .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
92        .await
93        .map_err(|e| anyhow::anyhow!("Failed to terminate Binance execution tasks: {e}"))?;
94    Ok(())
95}
96
97/// Polls the cache until the account is registered or timeout is reached.
98///
99/// Each iteration borrows and drops the cache Ref to avoid holding the
100/// RefCell borrow across await points, which would block mutable access
101/// when the account state is registered by another task.
102///
103/// # Errors
104///
105/// Returns an error if the timeout is reached before the account is registered.
106pub async fn await_account_registered(
107    core: &ExecutionClientCore,
108    account_id: AccountId,
109    timeout_secs: f64,
110) -> anyhow::Result<()> {
111    if core.cache().account(&account_id).is_some() {
112        log::info!("Account {account_id} registered");
113        return Ok(());
114    }
115
116    let start = Instant::now();
117    let timeout = Duration::from_secs_f64(timeout_secs);
118    let interval = Duration::from_millis(10);
119
120    loop {
121        tokio::time::sleep(interval).await;
122
123        if core.cache().account(&account_id).is_some() {
124            log::info!("Account {account_id} registered");
125            return Ok(());
126        }
127
128        if start.elapsed() >= timeout {
129            anyhow::bail!(
130                "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
131            );
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use std::{cell::RefCell, rc::Rc};
139
140    use nautilus_common::cache::Cache;
141    use nautilus_live::ExecutionClientCore;
142    use nautilus_model::{
143        accounts::{AccountAny, CashAccount},
144        enums::{AccountType, OmsType, OrderType},
145        events::AccountState,
146        identifiers::{AccountId, TraderId},
147        orders::{OrderTestBuilder, stubs::TestOrderEventStubs},
148        types::{AccountBalance, Money, Price, Quantity},
149    };
150    use rstest::rstest;
151
152    use super::*;
153    use crate::common::consts::{BINANCE_CLIENT_ID, BINANCE_VENUE};
154
155    #[rstest]
156    #[case::accepted(
157        "BINANCE-001",
158        "BINANCE",
159        "OWNED-002",
160        "BTCUSDT.BINANCE",
161        (true, true, false),
162        false,
163        1
164    )]
165    #[case::submitted(
166        "BINANCE-001",
167        "BINANCE",
168        "OWNED-002",
169        "BTCUSDT.BINANCE",
170        (true, false, false),
171        false,
172        1
173    )]
174    #[case::other_account(
175        "BINANCE-002",
176        "BINANCE",
177        "OWNED-002",
178        "BTCUSDT.BINANCE",
179        (true, true, false),
180        false,
181        0
182    )]
183    #[case::other_client(
184        "BINANCE-001",
185        "OTHER",
186        "OWNED-002",
187        "BTCUSDT.BINANCE",
188        (true, true, false),
189        false,
190        0
191    )]
192    #[case::unbound(
193        "BINANCE-001",
194        "",
195        "OWNED-002",
196        "BTCUSDT.BINANCE",
197        (true, true, false),
198        false,
199        0
200    )]
201    #[case::external(
202        "BINANCE-001",
203        "BINANCE",
204        "EXTERNAL",
205        "BTCUSDT.BINANCE",
206        (true, true, false),
207        false,
208        0
209    )]
210    #[case::other_venue(
211        "BINANCE-001",
212        "BINANCE",
213        "OWNED-002",
214        "BTCUSDT.OTHER",
215        (true, true, false),
216        false,
217        0
218    )]
219    #[case::initialized(
220        "BINANCE-001",
221        "BINANCE",
222        "OWNED-002",
223        "BTCUSDT.BINANCE",
224        (false, false, false),
225        false,
226        0
227    )]
228    #[case::closed(
229        "BINANCE-001",
230        "BINANCE",
231        "OWNED-002",
232        "BTCUSDT.BINANCE",
233        (true, true, true),
234        false,
235        0
236    )]
237    #[case::claimed_external(
238        "BINANCE-001",
239        "BINANCE",
240        "OWNED-002",
241        "BTCUSDT.BINANCE",
242        (false, true, false),
243        true,
244        0
245    )]
246    #[case::reconciled_local(
247        "BINANCE-001",
248        "BINANCE",
249        "OWNED-002",
250        "BTCUSDT.BINANCE",
251        (true, true, false),
252        true,
253        1
254    )]
255    fn test_active_orders_selects_only_retained_local_owners(
256        #[case] account: &str,
257        #[case] client: &str,
258        #[case] strategy: &str,
259        #[case] instrument: &str,
260        #[case] lifecycle: (bool, bool, bool),
261        #[case] reconciled: bool,
262        #[case] expected_count: usize,
263    ) {
264        let (submitted, accepted, closed) = lifecycle;
265        let cache = Rc::new(RefCell::new(Cache::default()));
266        let core = create_test_core(cache.clone(), AccountId::from("BINANCE-001"));
267        let order = OrderTestBuilder::new(OrderType::Limit)
268            .strategy_id(strategy.into())
269            .instrument_id(instrument.into())
270            .quantity(Quantity::from("0.002"))
271            .price(Price::from("45678.90"))
272            .build();
273        let cid = order.client_order_id();
274        cache
275            .borrow_mut()
276            .add_order(
277                order.clone(),
278                None,
279                (!client.is_empty()).then(|| client.into()),
280                false,
281            )
282            .unwrap();
283
284        if submitted {
285            cache
286                .borrow_mut()
287                .update_order(&TestOrderEventStubs::submitted(&order, account.into()))
288                .unwrap();
289        }
290
291        if accepted {
292            let OrderEventAny::Accepted(mut event) =
293                TestOrderEventStubs::accepted(&order, account.into(), "123".into())
294            else {
295                panic!("Expected acceptance stub")
296            };
297
298            event.reconciliation = reconciled;
299            cache
300                .borrow_mut()
301                .update_order(&OrderEventAny::Accepted(event))
302                .unwrap();
303        }
304
305        if closed {
306            cache
307                .borrow_mut()
308                .update_order(&TestOrderEventStubs::canceled(
309                    &order,
310                    account.into(),
311                    Some("123".into()),
312                ))
313                .unwrap();
314        }
315
316        let selected = active_orders(&core);
317        assert_eq!(selected.len(), expected_count);
318
319        if expected_count == 1 {
320            assert_eq!(selected[0], cache.borrow().order_owned(&cid).unwrap());
321        }
322    }
323
324    #[rstest]
325    #[tokio::test]
326    async fn test_spawn_task_unregisters_finished_task_before_shutdown() {
327        let pending_tasks = TaskGroup::new();
328
329        spawn_task(&pending_tasks, "test task", async { Ok(()) });
330        tokio::time::timeout(Duration::from_secs(1), async {
331            while !pending_tasks.all_finished() {
332                tokio::task::yield_now().await;
333            }
334        })
335        .await
336        .expect("task should finish");
337
338        assert!(pending_tasks.is_empty());
339        abort_pending_tasks(&pending_tasks);
340        await_pending_tasks(&pending_tasks)
341            .await
342            .expect("task shutdown");
343        assert!(pending_tasks.is_empty());
344    }
345
346    #[rstest]
347    #[tokio::test]
348    async fn test_abort_pending_tasks_aborts_running_tasks() {
349        let (drop_tx, drop_rx) = tokio::sync::oneshot::channel();
350        let guard = AbortDropSignal { tx: Some(drop_tx) };
351
352        let pending_tasks = TaskGroup::new();
353        pending_tasks
354            .spawn(async move {
355                let _guard = guard;
356                tokio::time::sleep(Duration::from_secs(60)).await;
357            })
358            .expect("task spawn");
359
360        abort_pending_tasks(&pending_tasks);
361        await_pending_tasks(&pending_tasks)
362            .await
363            .expect("task shutdown");
364
365        assert!(pending_tasks.is_empty());
366        tokio::time::timeout(Duration::from_secs(1), drop_rx)
367            .await
368            .expect("Aborted task should drop its future")
369            .expect("Drop signal should be sent");
370    }
371
372    #[rstest]
373    #[tokio::test]
374    async fn test_await_account_registered_returns_when_account_is_added() {
375        let account_id = AccountId::from("BINANCE-001");
376        let cache = Rc::new(RefCell::new(Cache::default()));
377        let core = create_test_core(cache.clone(), account_id);
378
379        let wait_fut = await_account_registered(&core, account_id, 0.5);
380        let register_fut = async move {
381            tokio::time::sleep(Duration::from_millis(20)).await;
382            add_test_account_to_cache(&cache, account_id);
383        };
384
385        let (result, ()) = tokio::join!(wait_fut, register_fut);
386        result.unwrap();
387    }
388
389    #[rstest]
390    #[tokio::test]
391    async fn test_await_account_registered_times_out() {
392        let account_id = AccountId::from("BINANCE-001");
393        let cache = Rc::new(RefCell::new(Cache::default()));
394        let core = create_test_core(cache, account_id);
395
396        let error = await_account_registered(&core, account_id, 0.02)
397            .await
398            .expect_err("Missing account should time out");
399
400        assert!(error.to_string().contains("BINANCE-001"));
401    }
402
403    struct AbortDropSignal {
404        tx: Option<tokio::sync::oneshot::Sender<()>>,
405    }
406
407    impl Drop for AbortDropSignal {
408        fn drop(&mut self) {
409            if let Some(tx) = self.tx.take() {
410                let _ = tx.send(());
411            }
412        }
413    }
414
415    fn create_test_core(cache: Rc<RefCell<Cache>>, account_id: AccountId) -> ExecutionClientCore {
416        ExecutionClientCore::new(
417            TraderId::from("TESTER-001"),
418            *BINANCE_CLIENT_ID,
419            *BINANCE_VENUE,
420            OmsType::Hedging,
421            account_id,
422            AccountType::Cash,
423            None,
424            cache,
425        )
426    }
427
428    fn add_test_account_to_cache(cache: &Rc<RefCell<Cache>>, account_id: AccountId) {
429        let state = AccountState::new(
430            account_id,
431            AccountType::Cash,
432            vec![AccountBalance::new(
433                Money::from("1.0 BTC"),
434                Money::from("0 BTC"),
435                Money::from("1.0 BTC"),
436            )],
437            vec![],
438            true,
439            nautilus_core::UUID4::new(),
440            nautilus_core::UnixNanos::default(),
441            nautilus_core::UnixNanos::default(),
442            None,
443        );
444
445        let account = AccountAny::Cash(CashAccount::new(state, true, false));
446        cache.borrow_mut().add_account(account).unwrap();
447    }
448}