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