Skip to main content

nautilus_trading/strategy/
core.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
16use std::{
17    cell::{Ref, RefCell, RefMut},
18    fmt::Debug,
19    rc::Rc,
20};
21
22use ahash::AHashMap;
23use nautilus_common::{
24    actor::{DataActorConfig, DataActorCore, DataActorNative},
25    cache::Cache,
26    clock::Clock,
27    factories::OrderFactory,
28};
29use nautilus_core::{
30    UUID4,
31    correctness::{CorrectnessResult, CorrectnessResultExt, FAILED},
32};
33use nautilus_execution::order_manager::manager::OrderManager;
34use nautilus_model::identifiers::{
35    ActorId, ClientOrderId, StrategyId, TraderId, UNASSIGNED_ORDER_ID_TAG, check_order_id_tag,
36    normalize_order_id_tag,
37};
38use nautilus_portfolio::portfolio::Portfolio;
39use ustr::Ustr;
40
41use super::{
42    api::{OrderApi, PortfolioApi},
43    config::StrategyConfig,
44};
45
46/// The core component of a [`Strategy`](crate::strategy::Strategy), managing data, orders,
47/// and state.
48///
49/// This struct is intended to be held as a member within a user's custom strategy struct.
50/// Use the `nautilus_strategy!` macro to provide the trait accessors required by
51/// [`Strategy`](crate::strategy::Strategy), [`StrategyNative`], and
52/// [`DataActor`](nautilus_common::actor::DataActor). It does not deref to
53/// [`DataActorCore`]; normal strategy logic should use facade methods on the
54/// strategy value.
55pub struct StrategyCore {
56    pub(crate) actor: DataActorCore,
57    /// The strategy configuration.
58    pub config: StrategyConfig,
59    strategy_id: Option<StrategyId>,
60    order_id_tag: Option<String>,
61    pub(crate) order_manager: Option<OrderManager>,
62    pub(crate) order_factory: Option<Rc<RefCell<OrderFactory>>>,
63    pub(crate) portfolio: Option<Rc<RefCell<Portfolio>>>,
64    pub(crate) gtd_timers: AHashMap<ClientOrderId, Ustr>,
65    pub(crate) managed_time_event_last_id: Option<UUID4>,
66    pub(crate) is_exiting: bool,
67    pub(crate) pending_stop: bool,
68    pub(crate) market_exit_attempts: u64,
69    pub(crate) market_exit_timer_name: Ustr,
70    pub(crate) market_exit_tag: Ustr,
71}
72
73impl Debug for StrategyCore {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct(stringify!(StrategyCore))
76            .field("actor", &self.actor)
77            .field("config", &self.config)
78            .field("strategy_id", &self.strategy_id)
79            .field("order_id_tag", &self.order_id_tag)
80            .field("order_manager", &self.order_manager)
81            .field("order_factory", &self.order_factory)
82            .field("is_exiting", &self.is_exiting)
83            .field("pending_stop", &self.pending_stop)
84            .field("market_exit_attempts", &self.market_exit_attempts)
85            .finish()
86    }
87}
88
89/// Native-only access to internal strategy runtime state.
90///
91/// Use this trait from engine, runtime, testkit, or opt-in native strategy
92/// code when direct access to host runtime objects matters for an explicit
93/// latency-sensitive path, or when host integration code needs access below
94/// the facade API.
95///
96/// Do not import this trait in strategy code intended to run through Python or
97/// the plug-in authoring surface. Those surfaces should use facade methods such
98/// as `order()` and `portfolio()`, because native borrows, `Rc<RefCell<_>>`, and
99/// core references do not cross those boundaries.
100pub trait StrategyNative {
101    /// Returns the strategy core.
102    fn strategy_core(&self) -> &StrategyCore;
103
104    /// Returns the mutable strategy core.
105    fn strategy_core_mut(&mut self) -> &mut StrategyCore;
106
107    /// Returns a mutable borrow of the order factory.
108    ///
109    /// # Panics
110    ///
111    /// Panics if the strategy has not been registered.
112    fn order_factory(&mut self) -> RefMut<'_, OrderFactory> {
113        self.strategy_core_mut()
114            .order_factory
115            .as_ref()
116            .expect("Strategy not registered: OrderFactory not initialized")
117            .borrow_mut()
118    }
119
120    /// Returns a clone of the reference-counted order factory.
121    ///
122    /// # Panics
123    ///
124    /// Panics if the strategy has not been registered.
125    fn order_factory_rc(&self) -> Rc<RefCell<OrderFactory>> {
126        self.strategy_core()
127            .order_factory
128            .as_ref()
129            .expect("Strategy not registered: OrderFactory not initialized")
130            .clone()
131    }
132
133    /// Returns a clone of the reference-counted portfolio.
134    ///
135    /// # Panics
136    ///
137    /// Panics if the strategy has not been registered.
138    fn portfolio_rc(&self) -> Rc<RefCell<Portfolio>> {
139        self.strategy_core()
140            .portfolio
141            .as_ref()
142            .expect("Strategy not registered: Portfolio not initialized")
143            .clone()
144    }
145}
146
147impl StrategyCore {
148    /// Creates a new [`StrategyCore`] instance with correctness checking.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the configured order ID tag contains the '-' strategy ID separator,
153    /// or if composing it into the strategy ID does not produce a valid [`StrategyId`].
154    pub fn new_checked(config: StrategyConfig) -> CorrectnessResult<Self> {
155        if let Some(order_id_tag) = config.order_id_tag.as_deref() {
156            check_order_id_tag(order_id_tag)?;
157        }
158
159        let configured_strategy_id = config.strategy_id;
160        let configured_order_id_tag = normalize_order_id_tag(config.order_id_tag.as_deref());
161        let strategy_id = configured_strategy_id
162            .map(|id| strategy_id_with_order_id_tag(id, configured_order_id_tag))
163            .transpose()?;
164        let order_id_tag = strategy_id
165            .map(|id| id.get_tag().to_string())
166            .or_else(|| configured_order_id_tag.map(str::to_string));
167
168        let actor_config = DataActorConfig {
169            actor_id: Some(
170                strategy_id
171                    .map_or_else(unassigned_strategy_actor_id, |id| ActorId::new(id.inner())),
172            ),
173            log_events: config.log_events,
174            log_commands: config.log_commands,
175        };
176
177        let strategy_id_str = strategy_id
178            .map(|id| id.inner().to_string())
179            .unwrap_or_default();
180        let market_exit_timer_name = Ustr::from(&format!("MARKET_EXIT_CHECK:{strategy_id_str}"));
181
182        Ok(Self {
183            actor: DataActorCore::new(actor_config),
184            config,
185            strategy_id,
186            order_id_tag,
187            order_manager: None,
188            order_factory: None,
189            portfolio: None,
190            gtd_timers: AHashMap::new(),
191            managed_time_event_last_id: None,
192            is_exiting: false,
193            pending_stop: false,
194            market_exit_attempts: 0,
195            market_exit_timer_name,
196            market_exit_tag: Ustr::from("MARKET_EXIT"),
197        })
198    }
199
200    /// Creates a new [`StrategyCore`] instance.
201    ///
202    /// # Panics
203    ///
204    /// Panics if the configured order ID tag contains the '-' strategy ID separator,
205    /// or if composing it into the strategy ID does not produce a valid [`StrategyId`].
206    #[must_use]
207    pub fn new(config: StrategyConfig) -> Self {
208        Self::new_checked(config).expect_display(FAILED)
209    }
210
211    /// Returns the strategy configuration.
212    #[must_use]
213    pub fn config(&self) -> &StrategyConfig {
214        &self.config
215    }
216
217    /// Changes the strategy ID before registration.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if composing the current order ID tag into `strategy_id` does not
222    /// produce a valid [`StrategyId`].
223    pub fn change_id(&mut self, strategy_id: StrategyId) -> anyhow::Result<()> {
224        let strategy_id = strategy_id_with_order_id_tag(strategy_id, self.order_id_tag())?;
225        self.set_runtime_strategy_id(strategy_id);
226        Ok(())
227    }
228
229    /// Changes the order ID tag before registration.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if `order_id_tag` contains the '-' strategy ID separator, or if
234    /// composing it into the current strategy ID does not produce a valid [`StrategyId`].
235    pub fn change_order_id_tag(&mut self, order_id_tag: &str) -> anyhow::Result<()> {
236        check_order_id_tag(order_id_tag)?;
237
238        let normalized_order_id_tag =
239            normalize_order_id_tag(Some(order_id_tag)).map(str::to_string);
240
241        if let Some(strategy_id) = self.strategy_id
242            && let Some(order_id_tag) = normalized_order_id_tag.as_deref()
243        {
244            let strategy_id = strategy_id_with_order_id_tag(strategy_id, Some(order_id_tag))?;
245            self.set_runtime_strategy_id(strategy_id);
246        } else {
247            self.order_id_tag = normalized_order_id_tag;
248        }
249
250        Ok(())
251    }
252
253    fn set_runtime_strategy_id(&mut self, strategy_id: StrategyId) {
254        let actor_id = ActorId::new(strategy_id.inner());
255        self.actor.actor_id = actor_id;
256        self.actor.config.actor_id = Some(actor_id);
257        self.strategy_id = Some(strategy_id);
258        self.order_id_tag = Some(strategy_id.get_tag().to_string());
259        self.market_exit_timer_name = Ustr::from(&format!("MARKET_EXIT_CHECK:{strategy_id}"));
260    }
261
262    /// Returns the runtime order ID tag.
263    #[must_use]
264    pub fn order_id_tag(&self) -> Option<&str> {
265        self.order_id_tag.as_deref()
266    }
267
268    /// Returns the runtime strategy ID.
269    #[must_use]
270    pub fn strategy_id(&self) -> Option<StrategyId> {
271        self.strategy_id
272    }
273
274    /// Registers the strategy with the trading engine components.
275    ///
276    /// This is typically called by the framework when the strategy is added to an engine.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if the configured order ID tag contains the '-' strategy ID separator,
281    /// or if registration with the actor core fails.
282    pub fn register(
283        &mut self,
284        trader_id: TraderId,
285        clock: Rc<RefCell<dyn Clock>>,
286        cache: Rc<RefCell<Cache>>,
287        portfolio: Rc<RefCell<Portfolio>>,
288    ) -> anyhow::Result<()> {
289        // Guards a config built without `StrategyConfig::validate`, such as a struct literal
290        if let Some(order_id_tag) = self.config.order_id_tag.as_deref() {
291            check_order_id_tag(order_id_tag)?;
292        }
293
294        let strategy_id = StrategyId::new(self.actor.actor_id.inner());
295
296        self.actor
297            .register(trader_id, clock.clone(), cache.clone())?;
298
299        // Update market exit timer name with actual strategy ID
300        self.market_exit_timer_name = Ustr::from(&format!("MARKET_EXIT_CHECK:{strategy_id}"));
301
302        self.strategy_id = Some(strategy_id);
303        self.order_id_tag = Some(strategy_id.get_tag().to_string());
304
305        self.order_factory = Some(Rc::new(RefCell::new(OrderFactory::new(
306            trader_id,
307            strategy_id,
308            None,
309            None,
310            clock.clone(),
311            self.config.use_uuid_client_order_ids,
312            self.config.use_hyphens_in_client_order_ids,
313        ))));
314
315        self.order_manager = Some(OrderManager::new(clock, cache, false));
316
317        self.portfolio = Some(portfolio);
318
319        Ok(())
320    }
321
322    /// Returns the user-facing order creation API.
323    ///
324    /// # Panics
325    ///
326    /// Panics if the strategy has not been registered.
327    #[must_use]
328    pub fn order(&self) -> OrderApi<'_> {
329        let order_factory = self
330            .order_factory
331            .as_ref()
332            .expect("Strategy not registered: OrderFactory not initialized");
333        OrderApi::new(order_factory.as_ref())
334    }
335
336    /// Returns the user-facing portfolio read API.
337    ///
338    /// # Panics
339    ///
340    /// Panics if the strategy has not been registered.
341    #[must_use]
342    pub(crate) fn portfolio_api(&self) -> PortfolioApi<'_> {
343        let portfolio = self
344            .portfolio
345            .as_ref()
346            .expect("Strategy not registered: Portfolio not initialized");
347        PortfolioApi::new(portfolio.as_ref())
348    }
349
350    pub(crate) fn actor_id(&self) -> ActorId {
351        self.actor.actor_id()
352    }
353
354    pub(crate) fn trader_id(&self) -> Option<TraderId> {
355        self.actor.trader_id()
356    }
357
358    pub(crate) fn clock_mut(&mut self) -> RefMut<'_, dyn Clock> {
359        DataActorNative::clock_mut(self)
360    }
361
362    pub(crate) fn cache_ref(&self) -> Ref<'_, Cache> {
363        DataActorNative::cache_ref(self)
364    }
365
366    pub(crate) fn cache_rc(&self) -> Rc<RefCell<Cache>> {
367        DataActorNative::cache_rc(self)
368    }
369
370    /// Resets the market exit state.
371    pub fn reset_market_exit_state(&mut self) {
372        self.is_exiting = false;
373        self.pending_stop = false;
374        self.market_exit_attempts = 0;
375    }
376}
377
378impl DataActorNative for StrategyCore {
379    fn core(&self) -> &DataActorCore {
380        &self.actor
381    }
382
383    fn core_mut(&mut self) -> &mut DataActorCore {
384        &mut self.actor
385    }
386}
387
388impl StrategyNative for StrategyCore {
389    fn strategy_core(&self) -> &StrategyCore {
390        self
391    }
392
393    fn strategy_core_mut(&mut self) -> &mut StrategyCore {
394        self
395    }
396}
397
398/// Returns the component identity for a strategy without a configured ID.
399///
400/// Registration replaces this with the class-derived ID and the assigned order ID tag. The
401/// unassigned tag keeps the identity convertible to a [`StrategyId`] until then.
402fn unassigned_strategy_actor_id() -> ActorId {
403    ActorId::from(format!(
404        "{}-{UNASSIGNED_ORDER_ID_TAG}",
405        stringify!(Strategy)
406    ))
407}
408
409fn strategy_id_with_order_id_tag(
410    strategy_id: StrategyId,
411    order_id_tag: Option<&str>,
412) -> CorrectnessResult<StrategyId> {
413    let Some(order_id_tag) = normalize_order_id_tag(order_id_tag) else {
414        return Ok(strategy_id);
415    };
416
417    if strategy_id.get_tag() == order_id_tag {
418        Ok(strategy_id)
419    } else {
420        StrategyId::new_checked(format!("{strategy_id}-{order_id_tag}"))
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use std::{cell::RefCell, rc::Rc};
427
428    use nautilus_common::{cache::Cache, clock::TestClock};
429    use nautilus_core::UnixNanos;
430    use nautilus_model::{
431        enums::{OrderSide, OrderType, TimeInForce, TrailingOffsetType, TriggerType},
432        identifiers::{AccountId, InstrumentId, StrategyId, TraderId},
433        orders::Order,
434        types::{Price, Quantity},
435    };
436    use nautilus_portfolio::portfolio::Portfolio;
437    use rstest::rstest;
438    use rust_decimal::Decimal;
439
440    use super::*;
441
442    fn create_test_config() -> StrategyConfig {
443        StrategyConfig {
444            strategy_id: Some(StrategyId::from("TEST-001")),
445            order_id_tag: Some("001".to_string()),
446            ..Default::default()
447        }
448    }
449
450    #[rstest]
451    fn test_strategy_core_new() {
452        let config = create_test_config();
453        let core = StrategyCore::new(config.clone());
454
455        assert_eq!(core.config.strategy_id, config.strategy_id);
456        assert_eq!(core.config.order_id_tag, config.order_id_tag);
457        assert_eq!(core.strategy_id(), config.strategy_id);
458        assert_eq!(core.order_id_tag(), Some("001"));
459        assert!(core.order_manager.is_none());
460        assert!(core.order_factory.is_none());
461        assert!(core.portfolio.is_none());
462        assert!(!core.is_exiting);
463        assert!(!core.pending_stop);
464        assert_eq!(core.market_exit_attempts, 0);
465    }
466
467    #[rstest]
468    fn test_strategy_core_new_without_configured_id_uses_the_unassigned_actor_id() {
469        let core = StrategyCore::new(StrategyConfig::default());
470
471        assert_eq!(core.actor_id(), ActorId::from("Strategy-None"));
472        assert_eq!(core.strategy_id(), None);
473        assert_eq!(core.order_id_tag(), None);
474        assert_eq!(
475            StrategyId::new(core.actor_id().inner()),
476            StrategyId::from("Strategy-None")
477        );
478    }
479
480    #[rstest]
481    fn test_strategy_core_new_applies_explicit_order_id_tag_to_strategy_id() {
482        let config = StrategyConfig {
483            strategy_id: Some(StrategyId::from("ExampleStrategy-XNAS")),
484            order_id_tag: Some("T01".to_string()),
485            ..Default::default()
486        };
487
488        let core = StrategyCore::new(config.clone());
489
490        assert_eq!(core.actor_id(), ActorId::from("ExampleStrategy-XNAS-T01"));
491        assert_eq!(core.config.strategy_id, config.strategy_id);
492        assert_eq!(core.config.order_id_tag, config.order_id_tag);
493        assert_eq!(
494            core.strategy_id(),
495            Some(StrategyId::from("ExampleStrategy-XNAS-T01"))
496        );
497        assert_eq!(core.order_id_tag(), Some("T01"));
498    }
499
500    #[rstest]
501    fn test_strategy_core_new_uses_strategy_tag_when_order_id_tag_is_omitted() {
502        let config = StrategyConfig {
503            strategy_id: Some(StrategyId::from("ExampleStrategy-XNAS")),
504            ..Default::default()
505        };
506
507        let core = StrategyCore::new(config.clone());
508
509        assert_eq!(core.actor_id(), ActorId::from("ExampleStrategy-XNAS"));
510        assert_eq!(core.config.strategy_id, config.strategy_id);
511        assert_eq!(core.config.order_id_tag, None);
512        assert_eq!(core.strategy_id(), config.strategy_id);
513        assert_eq!(core.order_id_tag(), Some("XNAS"));
514    }
515
516    #[rstest]
517    fn test_strategy_core_change_id_appends_existing_order_id_tag() {
518        let config = StrategyConfig {
519            order_id_tag: Some("T01".to_string()),
520            ..Default::default()
521        };
522        let mut core = StrategyCore::new(config);
523
524        core.change_id(StrategyId::from("ExampleStrategy-XNAS"))
525            .unwrap();
526
527        assert_eq!(core.actor_id(), ActorId::from("ExampleStrategy-XNAS-T01"));
528        assert_eq!(
529            core.strategy_id(),
530            Some(StrategyId::from("ExampleStrategy-XNAS-T01"))
531        );
532        assert_eq!(core.order_id_tag(), Some("T01"));
533    }
534
535    #[rstest]
536    fn test_strategy_core_change_order_id_tag_appends_to_existing_strategy_id() {
537        let config = StrategyConfig {
538            strategy_id: Some(StrategyId::from("ExampleStrategy-XNAS")),
539            ..Default::default()
540        };
541        let mut core = StrategyCore::new(config);
542
543        core.change_order_id_tag("T01").unwrap();
544
545        assert_eq!(core.actor_id(), ActorId::from("ExampleStrategy-XNAS-T01"));
546        assert_eq!(
547            core.strategy_id(),
548            Some(StrategyId::from("ExampleStrategy-XNAS-T01"))
549        );
550        assert_eq!(core.order_id_tag(), Some("T01"));
551    }
552
553    #[rstest]
554    fn test_strategy_core_change_order_id_tag_does_not_duplicate_matching_tag() {
555        let config = StrategyConfig {
556            strategy_id: Some(StrategyId::from("ExampleStrategy-XNAS-T01")),
557            ..Default::default()
558        };
559        let mut core = StrategyCore::new(config);
560
561        core.change_order_id_tag("T01").unwrap();
562
563        assert_eq!(core.actor_id(), ActorId::from("ExampleStrategy-XNAS-T01"));
564        assert_eq!(
565            core.strategy_id(),
566            Some(StrategyId::from("ExampleStrategy-XNAS-T01"))
567        );
568        assert_eq!(core.order_id_tag(), Some("T01"));
569    }
570
571    #[rstest]
572    fn test_strategy_core_new_checked_rejects_order_id_tag_with_separator() {
573        let config = StrategyConfig {
574            strategy_id: Some(StrategyId::from("HyphenTagStrategy-A-B")),
575            order_id_tag: Some("A-B".to_string()),
576            ..Default::default()
577        };
578
579        let error = StrategyCore::new_checked(config).unwrap_err();
580
581        assert_eq!(
582            error.to_string(),
583            "`order_id_tag` cannot contain the '-' strategy ID separator, was 'A-B'"
584        );
585    }
586
587    #[rstest]
588    #[case(Some("001".to_string()))]
589    #[case(Some("None".to_string()))]
590    #[case(Some(String::new()))]
591    #[case(None)]
592    fn test_strategy_core_new_checked_accepts_usable_order_id_tag(
593        #[case] order_id_tag: Option<String>,
594    ) {
595        let config = StrategyConfig {
596            strategy_id: Some(StrategyId::from("ExampleStrategy-XNAS")),
597            order_id_tag,
598            ..Default::default()
599        };
600
601        assert!(StrategyCore::new_checked(config).is_ok());
602    }
603
604    #[rstest]
605    #[should_panic(expected = "`order_id_tag` cannot contain the '-' strategy ID separator")]
606    fn test_strategy_core_new_panics_on_order_id_tag_with_separator() {
607        let config = StrategyConfig {
608            order_id_tag: Some("A-B".to_string()),
609            ..Default::default()
610        };
611
612        let _ = StrategyCore::new(config);
613    }
614
615    #[rstest]
616    fn test_strategy_core_change_order_id_tag_rejects_separator() {
617        let config = StrategyConfig {
618            strategy_id: Some(StrategyId::from("ExampleStrategy-XNAS")),
619            ..Default::default()
620        };
621        let mut core = StrategyCore::new(config);
622
623        let error = core.change_order_id_tag("A-B").unwrap_err();
624
625        assert_eq!(
626            error.to_string(),
627            "`order_id_tag` cannot contain the '-' strategy ID separator, was 'A-B'"
628        );
629        assert_eq!(core.actor_id(), ActorId::from("ExampleStrategy-XNAS"));
630        assert_eq!(
631            core.strategy_id(),
632            Some(StrategyId::from("ExampleStrategy-XNAS"))
633        );
634        assert_eq!(core.order_id_tag(), Some("XNAS"));
635    }
636
637    #[rstest]
638    fn test_strategy_core_new_checked_rejects_non_ascii_order_id_tag() {
639        let config = StrategyConfig {
640            strategy_id: Some(StrategyId::from("ExampleStrategy-XNAS")),
641            order_id_tag: Some("T01€".to_string()),
642            ..Default::default()
643        };
644
645        let error = StrategyCore::new_checked(config).unwrap_err();
646
647        assert_eq!(
648            error.to_string(),
649            "invalid string for 'value' contained a non-ASCII char, was 'ExampleStrategy-XNAS-T01€'"
650        );
651    }
652
653    #[rstest]
654    #[should_panic(
655        expected = "invalid string for 'value' contained a non-ASCII char, was 'ExampleStrategy-XNAS-T01€'"
656    )]
657    fn test_strategy_core_new_panics_on_non_ascii_order_id_tag() {
658        let config = StrategyConfig {
659            strategy_id: Some(StrategyId::from("ExampleStrategy-XNAS")),
660            order_id_tag: Some("T01€".to_string()),
661            ..Default::default()
662        };
663
664        let _ = StrategyCore::new(config);
665    }
666
667    #[rstest]
668    fn test_strategy_core_change_order_id_tag_rejects_non_ascii() {
669        let config = StrategyConfig {
670            strategy_id: Some(StrategyId::from("ExampleStrategy-XNAS")),
671            ..Default::default()
672        };
673        let mut core = StrategyCore::new(config);
674
675        let error = core.change_order_id_tag("T01€").unwrap_err();
676
677        assert_eq!(
678            error.to_string(),
679            "invalid string for 'value' contained a non-ASCII char, was 'ExampleStrategy-XNAS-T01€'"
680        );
681        assert_eq!(core.actor_id(), ActorId::from("ExampleStrategy-XNAS"));
682        assert_eq!(
683            core.strategy_id(),
684            Some(StrategyId::from("ExampleStrategy-XNAS"))
685        );
686        assert_eq!(core.order_id_tag(), Some("XNAS"));
687    }
688
689    #[rstest]
690    fn test_strategy_core_change_id_rejects_non_ascii_order_id_tag() {
691        let config = StrategyConfig {
692            order_id_tag: Some("T01€".to_string()),
693            ..Default::default()
694        };
695        let mut core = StrategyCore::new(config);
696
697        let error = core
698            .change_id(StrategyId::from("ExampleStrategy-XNAS"))
699            .unwrap_err();
700
701        assert_eq!(
702            error.to_string(),
703            "invalid string for 'value' contained a non-ASCII char, was 'ExampleStrategy-XNAS-T01€'"
704        );
705        assert_eq!(core.actor_id(), ActorId::from("Strategy-None"));
706        assert_eq!(core.strategy_id(), None);
707        assert_eq!(core.order_id_tag(), Some("T01€"));
708    }
709
710    #[rstest]
711    fn test_strategy_core_change_order_id_tag_without_strategy_id_stores_tag() {
712        let mut core = StrategyCore::new(StrategyConfig::default());
713
714        core.change_order_id_tag("T01").unwrap();
715
716        assert_eq!(core.actor_id(), ActorId::from("Strategy-None"));
717        assert_eq!(core.strategy_id(), None);
718        assert_eq!(core.order_id_tag(), Some("T01"));
719    }
720
721    #[rstest]
722    #[case("")]
723    #[case("None")]
724    fn test_strategy_core_change_order_id_tag_clears_unset_sentinel(#[case] order_id_tag: &str) {
725        let config = StrategyConfig {
726            strategy_id: Some(StrategyId::from("ExampleStrategy-XNAS")),
727            ..Default::default()
728        };
729        let mut core = StrategyCore::new(config);
730
731        core.change_order_id_tag(order_id_tag).unwrap();
732
733        assert_eq!(core.actor_id(), ActorId::from("ExampleStrategy-XNAS"));
734        assert_eq!(
735            core.strategy_id(),
736            Some(StrategyId::from("ExampleStrategy-XNAS"))
737        );
738        assert_eq!(core.order_id_tag(), None);
739    }
740
741    #[rstest]
742    fn test_strategy_core_register_rejects_configured_order_id_tag_with_separator() {
743        let config = StrategyConfig {
744            strategy_id: Some(StrategyId::from("HyphenTagStrategy-001")),
745            order_id_tag: Some("001".to_string()),
746            ..Default::default()
747        };
748        let mut core = StrategyCore::new(config);
749        core.config.order_id_tag = Some("A-B".to_string());
750
751        let trader_id = TraderId::from("TRADER-001");
752        let clock = Rc::new(RefCell::new(TestClock::new()));
753        let cache = Rc::new(RefCell::new(Cache::default()));
754        let portfolio = Rc::new(RefCell::new(Portfolio::new(
755            clock.clone(),
756            cache.clone(),
757            None,
758        )));
759
760        let error = core
761            .register(trader_id, clock, cache, portfolio)
762            .unwrap_err();
763
764        assert_eq!(
765            error.to_string(),
766            "`order_id_tag` cannot contain the '-' strategy ID separator, was 'A-B'"
767        );
768        assert!(core.order_factory.is_none());
769        assert!(core.order_manager.is_none());
770        assert!(core.portfolio.is_none());
771        assert_eq!(core.trader_id(), None);
772    }
773
774    #[rstest]
775    fn test_strategy_core_register() {
776        let config = create_test_config();
777        let mut core = StrategyCore::new(config);
778
779        let trader_id = TraderId::from("TRADER-001");
780        let clock = Rc::new(RefCell::new(TestClock::new()));
781        let cache = Rc::new(RefCell::new(Cache::default()));
782        let portfolio = Rc::new(RefCell::new(Portfolio::new(
783            clock.clone(),
784            cache.clone(),
785            None,
786        )));
787
788        let result = core.register(trader_id, clock, cache, portfolio);
789        assert!(result.is_ok());
790
791        assert!(core.order_manager.is_some());
792        assert!(core.order_factory.is_some());
793        assert!(core.portfolio.is_some());
794        assert_eq!(core.trader_id(), Some(trader_id));
795    }
796
797    #[rstest]
798    fn test_strategy_core_register_uses_order_id_tag_for_order_api_ids() {
799        let config = StrategyConfig {
800            strategy_id: Some(StrategyId::from("ExampleStrategy-XNAS")),
801            order_id_tag: Some("T01".to_string()),
802            ..Default::default()
803        };
804        let mut core = StrategyCore::new(config);
805
806        let trader_id = TraderId::from("TRADER-001");
807        let clock = Rc::new(RefCell::new(TestClock::new()));
808        let cache = Rc::new(RefCell::new(Cache::default()));
809        let portfolio = Rc::new(RefCell::new(Portfolio::new(
810            clock.clone(),
811            cache.clone(),
812            None,
813        )));
814
815        core.register(trader_id, clock, cache, portfolio).unwrap();
816
817        let orders = core.order();
818        let client_order_id = orders.generate_client_order_id();
819        let order_list_id = orders.generate_order_list_id();
820
821        assert_eq!(
822            core.strategy_id(),
823            Some(StrategyId::from("ExampleStrategy-XNAS-T01"))
824        );
825        assert_eq!(client_order_id.as_str(), "O-19700101-000000-001-T01-1");
826        assert_eq!(order_list_id.as_str(), "OL-19700101-000000-001-T01-1");
827    }
828
829    #[rstest]
830    fn test_strategy_core_order_api_creates_orders() {
831        let core = registered_test_core();
832        let orders = core.order();
833
834        let market = orders.market(
835            InstrumentId::from("BTCUSDT.BINANCE"),
836            OrderSide::Buy,
837            Quantity::from("1.0"),
838            None,
839            None,
840            None,
841            None,
842            None,
843            None,
844            None,
845        );
846        let limit = orders.limit(
847            InstrumentId::from("BTCUSDT.BINANCE"),
848            OrderSide::Sell,
849            Quantity::from("2.0"),
850            Price::from("100.00"),
851            None,
852            None,
853            None,
854            None,
855            None,
856            None,
857            None,
858            None,
859            None,
860            None,
861            None,
862            None,
863        );
864
865        assert_eq!(market.order_type(), OrderType::Market);
866        assert_eq!(
867            market.client_order_id().as_str(),
868            "O-19700101-000000-001-001-1"
869        );
870        assert_eq!(limit.order_type(), OrderType::Limit);
871        assert_eq!(
872            limit.client_order_id().as_str(),
873            "O-19700101-000000-001-001-2"
874        );
875    }
876
877    #[rstest]
878    fn test_strategy_core_order_api_creates_remaining_order_types() {
879        let core = registered_test_core();
880        let orders = core.order();
881        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
882        let trigger_instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
883        let expire_time = UnixNanos::from(1_000);
884        let display_qty = Quantity::from("0.5");
885
886        let stop_market = orders.stop_market(
887            instrument_id,
888            OrderSide::Buy,
889            Quantity::from("1.0"),
890            Price::from("99.00"),
891            Some(TriggerType::LastPrice),
892            Some(TimeInForce::Gtd),
893            Some(expire_time),
894            Some(true),
895            Some(false),
896            Some(display_qty),
897            Some(TriggerType::BidAsk),
898            Some(trigger_instrument_id),
899            None,
900            None,
901            None,
902            None,
903        );
904        let stop_limit = orders.stop_limit(
905            instrument_id,
906            OrderSide::Sell,
907            Quantity::from("1.1"),
908            Price::from("101.00"),
909            Price::from("100.50"),
910            Some(TriggerType::LastPrice),
911            Some(TimeInForce::Gtd),
912            Some(expire_time),
913            Some(true),
914            Some(false),
915            Some(false),
916            Some(display_qty),
917            Some(TriggerType::BidAsk),
918            Some(trigger_instrument_id),
919            None,
920            None,
921            None,
922            None,
923        );
924        let market_to_limit = orders.market_to_limit(
925            instrument_id,
926            OrderSide::Buy,
927            Quantity::from("1.2"),
928            Some(TimeInForce::Gtd),
929            Some(expire_time),
930            Some(true),
931            Some(false),
932            Some(display_qty),
933            None,
934            None,
935            None,
936            None,
937        );
938        let market_if_touched = orders.market_if_touched(
939            instrument_id,
940            OrderSide::Sell,
941            Quantity::from("1.3"),
942            Price::from("98.50"),
943            Some(TriggerType::LastPrice),
944            Some(TimeInForce::Gtd),
945            Some(expire_time),
946            Some(false),
947            Some(false),
948            Some(TriggerType::BidAsk),
949            Some(trigger_instrument_id),
950            None,
951            None,
952            None,
953            None,
954        );
955        let limit_if_touched = orders.limit_if_touched(
956            instrument_id,
957            OrderSide::Buy,
958            Quantity::from("1.4"),
959            Price::from("97.50"),
960            Price::from("97.00"),
961            Some(TriggerType::LastPrice),
962            Some(TimeInForce::Gtd),
963            Some(expire_time),
964            Some(true),
965            Some(false),
966            Some(false),
967            Some(display_qty),
968            Some(TriggerType::BidAsk),
969            Some(trigger_instrument_id),
970            None,
971            None,
972            None,
973            None,
974        );
975        let trailing_stop_market = orders.trailing_stop_market(
976            instrument_id,
977            OrderSide::Sell,
978            Quantity::from("1.5"),
979            Decimal::new(25, 2),
980            Some(TrailingOffsetType::Price),
981            Some(Price::from("105.00")),
982            Some(Price::from("104.50")),
983            Some(TriggerType::LastPrice),
984            Some(TimeInForce::Gtd),
985            Some(expire_time),
986            Some(false),
987            Some(false),
988            Some(display_qty),
989            Some(TriggerType::BidAsk),
990            Some(trigger_instrument_id),
991            None,
992            None,
993            None,
994            None,
995        );
996        let trailing_stop_limit = orders.trailing_stop_limit(
997            instrument_id,
998            OrderSide::Buy,
999            Quantity::from("1.6"),
1000            Price::from("96.00"),
1001            Decimal::new(10, 2),
1002            Decimal::new(50, 2),
1003            Some(TrailingOffsetType::Price),
1004            Some(Price::from("97.00")),
1005            Some(Price::from("96.50")),
1006            Some(TriggerType::LastPrice),
1007            Some(TimeInForce::Gtd),
1008            Some(expire_time),
1009            Some(true),
1010            Some(false),
1011            Some(false),
1012            Some(display_qty),
1013            Some(TriggerType::BidAsk),
1014            Some(trigger_instrument_id),
1015            None,
1016            None,
1017            None,
1018            None,
1019        );
1020        let mut list_orders = vec![market_to_limit.clone(), stop_limit.clone()];
1021        let order_list = orders.create_list(&mut list_orders, expire_time);
1022
1023        assert_eq!(stop_market.order_type(), OrderType::StopMarket);
1024        assert_eq!(stop_market.trigger_price(), Some(Price::from("99.00")));
1025        assert_eq!(stop_market.trigger_type(), Some(TriggerType::LastPrice));
1026        assert_eq!(stop_market.time_in_force(), TimeInForce::Gtd);
1027        assert_eq!(stop_market.expire_time(), Some(expire_time));
1028        assert!(stop_market.is_reduce_only());
1029        assert_eq!(stop_market.display_qty(), Some(display_qty));
1030        assert_eq!(stop_market.emulation_trigger(), Some(TriggerType::BidAsk));
1031        assert_eq!(
1032            stop_market.trigger_instrument_id(),
1033            Some(trigger_instrument_id)
1034        );
1035
1036        assert_eq!(stop_limit.order_type(), OrderType::StopLimit);
1037        assert_eq!(stop_limit.price(), Some(Price::from("101.00")));
1038        assert_eq!(stop_limit.trigger_price(), Some(Price::from("100.50")));
1039        assert!(stop_limit.is_post_only());
1040
1041        assert_eq!(market_to_limit.order_type(), OrderType::MarketToLimit);
1042        assert_eq!(market_to_limit.time_in_force(), TimeInForce::Gtd);
1043        assert_eq!(market_to_limit.expire_time(), Some(expire_time));
1044        assert!(market_to_limit.is_reduce_only());
1045        assert_eq!(market_to_limit.display_qty(), Some(display_qty));
1046
1047        assert_eq!(market_if_touched.order_type(), OrderType::MarketIfTouched);
1048        assert_eq!(
1049            market_if_touched.trigger_price(),
1050            Some(Price::from("98.50"))
1051        );
1052        assert_eq!(
1053            market_if_touched.trigger_type(),
1054            Some(TriggerType::LastPrice)
1055        );
1056
1057        assert_eq!(limit_if_touched.order_type(), OrderType::LimitIfTouched);
1058        assert_eq!(limit_if_touched.price(), Some(Price::from("97.50")));
1059        assert_eq!(limit_if_touched.trigger_price(), Some(Price::from("97.00")));
1060        assert!(limit_if_touched.is_post_only());
1061
1062        assert_eq!(
1063            trailing_stop_market.order_type(),
1064            OrderType::TrailingStopMarket
1065        );
1066        assert_eq!(
1067            trailing_stop_market.trailing_offset(),
1068            Some(Decimal::new(25, 2))
1069        );
1070        assert_eq!(
1071            trailing_stop_market.trailing_offset_type(),
1072            Some(TrailingOffsetType::Price)
1073        );
1074        assert_eq!(
1075            trailing_stop_market.activation_price(),
1076            Some(Price::from("105.00"))
1077        );
1078        assert_eq!(
1079            trailing_stop_market.trigger_price(),
1080            Some(Price::from("104.50"))
1081        );
1082
1083        assert_eq!(
1084            trailing_stop_limit.order_type(),
1085            OrderType::TrailingStopLimit
1086        );
1087        assert_eq!(trailing_stop_limit.price(), Some(Price::from("96.00")));
1088        assert_eq!(
1089            trailing_stop_limit.limit_offset(),
1090            Some(Decimal::new(10, 2))
1091        );
1092        assert_eq!(
1093            trailing_stop_limit.trailing_offset(),
1094            Some(Decimal::new(50, 2))
1095        );
1096        assert_eq!(
1097            trailing_stop_limit.activation_price(),
1098            Some(Price::from("97.00"))
1099        );
1100        assert!(trailing_stop_limit.is_post_only());
1101
1102        assert_eq!(order_list.id, list_orders[0].order_list_id().unwrap());
1103        assert_eq!(order_list.id, list_orders[1].order_list_id().unwrap());
1104        assert_eq!(order_list.instrument_id, instrument_id);
1105        assert_eq!(
1106            order_list.client_order_ids,
1107            list_orders
1108                .iter()
1109                .map(Order::client_order_id)
1110                .collect::<Vec<_>>()
1111        );
1112    }
1113
1114    #[rstest]
1115    fn test_strategy_core_order_api_generates_ids() {
1116        let core = registered_test_core();
1117        let (client_order_id, order_list_id) = {
1118            let orders = core.order();
1119            (
1120                orders.generate_client_order_id(),
1121                orders.generate_order_list_id(),
1122            )
1123        };
1124
1125        let next_client_order_id = core.order().generate_client_order_id();
1126
1127        assert_eq!(client_order_id.as_str(), "O-19700101-000000-001-001-1");
1128        assert_eq!(order_list_id.as_str(), "OL-19700101-000000-001-001-1");
1129        assert_eq!(next_client_order_id.as_str(), "O-19700101-000000-001-001-2");
1130    }
1131
1132    #[rstest]
1133    fn test_strategy_core_order_api_creates_bracket_orders() {
1134        let core = registered_test_core();
1135
1136        let orders = core
1137            .order()
1138            .bracket()
1139            .instrument_id(InstrumentId::from("BTCUSDT.BINANCE"))
1140            .order_side(OrderSide::Buy)
1141            .quantity(Quantity::from("1.0"))
1142            .tp_price(Price::from("110.00"))
1143            .sl_trigger_price(Price::from("90.00"))
1144            .call();
1145        let order_list_id = orders[0].order_list_id();
1146
1147        assert_eq!(orders.len(), 3);
1148        assert_eq!(orders[0].order_type(), OrderType::Market);
1149        assert_eq!(orders[1].order_type(), OrderType::StopMarket);
1150        assert_eq!(orders[2].order_type(), OrderType::Limit);
1151        assert!(order_list_id.is_some());
1152        assert!(
1153            orders
1154                .iter()
1155                .all(|order| order.order_list_id() == order_list_id)
1156        );
1157    }
1158
1159    #[rstest]
1160    fn test_strategy_core_portfolio_api_returns_owned_reads() {
1161        let core = registered_test_core();
1162        let portfolio = core.portfolio_api();
1163        let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
1164        let venue = instrument_id.venue;
1165        let account_id = AccountId::from("SIM-001");
1166
1167        let is_initialized = portfolio.is_initialized();
1168        let balances_locked = portfolio.balances_locked(&venue);
1169        let initial_margins = portfolio.instrument_initial_margins(&venue);
1170        let maintenance_margins = portfolio.instrument_maintenance_margins(&venue);
1171        let unrealized_pnls = portfolio.unrealized_pnls(&venue, None);
1172        let realized_pnls = portfolio.realized_pnls(&venue, None);
1173        let net_exposures = portfolio.net_exposures(&venue, None);
1174        let unrealized_pnl = portfolio.unrealized_pnl(&instrument_id);
1175        let realized_pnl = portfolio.realized_pnl(&instrument_id);
1176        let total_pnl = portfolio.total_pnl(&instrument_id);
1177        let total_pnls = portfolio.total_pnls(&venue, None);
1178        let mark_values = portfolio.mark_values(&venue, None);
1179        let equity = portfolio.equity(&venue, None);
1180        let net_exposure = portfolio.net_exposure(&instrument_id, None);
1181        let is_flat = portfolio.is_net_flat(&instrument_id);
1182        let net_position = portfolio.net_position(&instrument_id);
1183        let missing_prices = portfolio.missing_price_instruments(&venue);
1184        let snapshots = portfolio.snapshots(&account_id);
1185        let recorded_realized_pnls = portfolio.recorded_realized_pnls();
1186        let built_snapshot = portfolio.build_snapshot(&account_id);
1187
1188        assert!(!is_initialized);
1189        assert!(balances_locked.is_empty());
1190        assert!(initial_margins.is_empty());
1191        assert!(maintenance_margins.is_empty());
1192        assert!(unrealized_pnls.is_some_and(|values| values.is_empty()));
1193        assert!(realized_pnls.is_some_and(|values| values.is_empty()));
1194        assert_eq!(net_exposures, None);
1195        assert_eq!(unrealized_pnl, None);
1196        assert_eq!(realized_pnl, None);
1197        assert_eq!(total_pnl, None);
1198        assert!(total_pnls.is_some_and(|values| values.is_empty()));
1199        assert!(mark_values.is_empty());
1200        assert!(equity.is_empty());
1201        assert_eq!(net_exposure, None);
1202        assert!(is_flat);
1203        assert_eq!(net_position, Decimal::ZERO);
1204        assert!(missing_prices.is_empty());
1205        assert!(snapshots.is_empty());
1206        assert!(recorded_realized_pnls.is_empty());
1207        assert_eq!(built_snapshot, None);
1208    }
1209
1210    #[rstest]
1211    fn test_strategy_core_actor_state_starts_unregistered() {
1212        let config = create_test_config();
1213        let core = StrategyCore::new(config);
1214
1215        assert!(core.trader_id().is_none());
1216    }
1217
1218    #[rstest]
1219    fn test_strategy_core_debug() {
1220        let config = create_test_config();
1221        let core = StrategyCore::new(config);
1222
1223        let debug_str = format!("{core:?}");
1224        assert!(debug_str.contains("StrategyCore"));
1225    }
1226
1227    fn registered_test_core() -> StrategyCore {
1228        let config = create_test_config();
1229        let mut core = StrategyCore::new(config);
1230
1231        let trader_id = TraderId::from("TRADER-001");
1232        let clock = Rc::new(RefCell::new(TestClock::new()));
1233        let cache = Rc::new(RefCell::new(Cache::default()));
1234        let portfolio = Rc::new(RefCell::new(Portfolio::new(
1235            clock.clone(),
1236            cache.clone(),
1237            None,
1238        )));
1239
1240        core.register(trader_id, clock, cache, portfolio).unwrap();
1241        core
1242    }
1243}