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