rustrade 0.4.0

Framework for building high-performance live-trading, paper-trading and back-testing systems
Documentation
use crate::{
    engine::state::{
        EngineState,
        asset::generate_empty_indexed_asset_states,
        connectivity::generate_empty_indexed_connectivity_states,
        instrument::generate_indexed_instrument_states,
        order::Orders,
        position::{OmsMode, PositionManager},
        trading::TradingState,
    },
    statistic::summary::asset::BalanceBasis,
};
use chrono::{DateTime, Utc};
use fnv::FnvHashMap;
use rustrade_execution::balance::{AssetBalance, Balance};
use rustrade_instrument::{
    Keyed,
    asset::{AssetIndex, ExchangeAsset, name::AssetNameInternal},
    exchange::{ExchangeId, ExchangeIndex},
    index::IndexedInstruments,
    instrument::{Instrument, InstrumentIndex},
};
use rustrade_integration::collection::snapshot::Snapshot;
use tracing::debug;

/// Builder utility for an [`EngineState`] instance.
#[derive(Debug, Clone)]
pub struct EngineStateBuilder<'a, GlobalData, FnInstrumentData> {
    instruments: &'a IndexedInstruments,
    trading_state: Option<TradingState>,
    time_engine_start: Option<DateTime<Utc>>,
    global: GlobalData,
    balances: FnvHashMap<ExchangeAsset<AssetNameInternal>, Balance>,
    instrument_data_init: FnInstrumentData,
    /// OMS mode applied to every instrument's [`PositionManager`] at construction.
    ///
    /// Defaults to [`OmsMode::Netting`]. Use [`OmsMode::Hedging`] for strategies that hold
    /// simultaneous long and short positions on the same instrument (e.g. options writing).
    oms_mode: OmsMode,
    /// [`BalanceBasis`] applied to every asset's `TearSheetAssetGenerator` at construction.
    ///
    /// Defaults to [`BalanceBasis::Gross`] (no change for existing/cash users). Use
    /// [`BalanceBasis::NetAsset`] to compute drawdown and end-of-session balance from net asset
    /// value on margin accounts — see its docs for the net-must-stay-positive precondition.
    balance_basis: BalanceBasis,
}

impl<'a, GlobalData, FnInstrumentData> EngineStateBuilder<'a, GlobalData, FnInstrumentData> {
    /// Construct a new `EngineStateBuilder` with a layout derived from [`IndexedInstruments`].
    ///
    /// Note that the rest of the [`EngineState`] data can be generated from defaults if that
    /// is all that is needed.
    ///
    /// Note that `ConnectivityStates` will be generated with
    /// [`generate_empty_indexed_connectivity_states`], defaulting to `Health::Reconnecting`.
    pub fn new(
        instruments: &'a IndexedInstruments,
        global: GlobalData,
        instrument_data_init: FnInstrumentData,
    ) -> Self {
        Self {
            instruments,
            time_engine_start: None,
            trading_state: None,
            global,
            balances: FnvHashMap::default(),
            instrument_data_init,
            oms_mode: OmsMode::Netting,
            balance_basis: BalanceBasis::default(),
        }
    }

    /// Optionally provide the initial `TradingState`.
    ///
    /// Defaults to `TradingState::Disabled`.
    pub fn trading_state(self, value: TradingState) -> Self {
        Self {
            trading_state: Some(value),
            ..self
        }
    }

    /// Optionally provide the `time_engine_start`.
    ///
    /// Providing this is useful for back-test scenarios where the time should be seeded with a
    /// "historical" clock time (eg/ from first historical `MarketEvent`).
    ///
    /// Defaults to `Utc::now`
    pub fn time_engine_start(self, value: DateTime<Utc>) -> Self {
        Self {
            time_engine_start: Some(value),
            ..self
        }
    }

    /// Optionally set the [`OmsMode`] for all instrument [`PositionManager`]s.
    ///
    /// Defaults to [`OmsMode::Netting`] (at most one position per instrument, backward-compatible).
    /// Set to [`OmsMode::Hedging`] for strategies that simultaneously hold long and short
    /// positions on the same instrument (e.g. options writing alongside long positions).
    ///
    /// # Note — `OmsMode::Hedging` and non-option instruments
    ///
    /// `OmsMode` is applied uniformly to all instruments. Hedging mode is intended for
    /// instruments where multiple concurrent positions are semantically valid (e.g. individual
    /// options legs). Applying it to spot or futures instruments will track each order's fills
    /// as a separate position slot (keyed by order ID) rather than a single net position,
    /// which is almost certainly not what you want for those asset classes.
    pub fn oms_mode(self, mode: OmsMode) -> Self {
        Self {
            oms_mode: mode,
            ..self
        }
    }

    /// Optionally set the [`BalanceBasis`] for all asset `TearSheetAssetGenerator`s.
    ///
    /// Defaults to [`BalanceBasis::Gross`] (drawdown and end-of-session balance computed from gross
    /// `Balance::total`), which is unchanged behaviour for existing and cash users. Set to
    /// [`BalanceBasis::NetAsset`] to compute them from net asset value (`total - borrowed`) on
    /// margin accounts.
    ///
    /// # Precondition (`NetAsset`)
    /// Net-asset drawdown is only well-defined while net asset stays **strictly positive**; see
    /// [`BalanceBasis::NetAsset`] for the silent-failure modes when it is not.
    pub fn balance_basis(self, basis: BalanceBasis) -> Self {
        Self {
            balance_basis: basis,
            ..self
        }
    }

    /// Optionally provide initial exchange asset `Balance`s.
    ///
    /// Useful for back-test scenarios where seeding EngineState with initial `Balance`s is
    /// required.
    ///
    /// Note the internal implementation uses a `HashMap`, so duplicate
    /// `ExchangeAsset<AssetNameInternal>` keys are overwritten.
    pub fn balances<BalanceIter, KeyedBalance>(mut self, balances: BalanceIter) -> Self
    where
        BalanceIter: IntoIterator<Item = KeyedBalance>,
        KeyedBalance: Into<Keyed<ExchangeAsset<AssetNameInternal>, Balance>>,
    {
        self.balances.extend(balances.into_iter().map(|keyed| {
            let Keyed { key, value } = keyed.into();

            (key, value)
        }));
        self
    }

    /// Use the builder data to generate the associated [`EngineState`].
    ///
    /// If optional data is not provided (eg/ Balances), default values are used (eg/ zero Balance).
    pub fn build<InstrumentData>(self) -> EngineState<GlobalData, InstrumentData>
    where
        FnInstrumentData: Fn(
            &'a Keyed<InstrumentIndex, Instrument<Keyed<ExchangeIndex, ExchangeId>, AssetIndex>>,
        ) -> InstrumentData,
    {
        let Self {
            instruments,
            time_engine_start,
            trading_state,
            global,
            balances,
            instrument_data_init,
            oms_mode,
            balance_basis,
        } = self;

        // Default if not provided
        let time_engine_start = time_engine_start.unwrap_or_else(|| {
            debug!("EngineStateBuilder using Utc::now as time_engine_start default");
            Utc::now()
        });
        let trading = trading_state.unwrap_or_default();

        // Construct empty ConnectivityStates
        let connectivity = generate_empty_indexed_connectivity_states(instruments);

        // Update empty AssetStates from provided exchange asset Balances
        let mut assets = generate_empty_indexed_asset_states(instruments, balance_basis);
        for (key, balance) in balances {
            assets
                .asset_mut(&key)
                .update_from_balance(Snapshot(&AssetBalance {
                    asset: key.asset,
                    balance,
                    time_exchange: time_engine_start,
                }))
        }

        // Generate empty InstrumentStates using provided FnInstrumentData etc.
        let instruments = generate_indexed_instrument_states(
            instruments,
            time_engine_start,
            move || PositionManager::new(oms_mode),
            Orders::default,
            instrument_data_init,
        );

        EngineState {
            trading,
            global,
            connectivity,
            assets,
            instruments,
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
mod tests {
    use super::*;
    use crate::{
        engine::state::EngineState,
        statistic::{summary::TradingSummaryGenerator, time::Annual365},
    };
    use rust_decimal::Decimal;
    use rustrade_instrument::{Underlying, instrument::Instrument};

    /// End-to-end seam: `EngineStateBuilder::balance_basis(NetAsset)` rides the asset generators
    /// into the on-demand `TradingSummaryGenerator` (which clones `AssetState.statistics`) and is
    /// stamped onto the output `TradingSummary.basis`.
    #[test]
    fn balance_basis_reaches_output_trading_summary() {
        let instruments = IndexedInstruments::builder()
            .add_instrument(Instrument::spot(
                ExchangeId::BinanceSpot,
                "binance_spot_btc_usdt",
                "BTCUSDT",
                Underlying::new("btc", "usdt"),
                None,
            ))
            .build();

        let state: EngineState<(), ()> = EngineState::builder(&instruments, (), |_| ())
            .balance_basis(BalanceBasis::NetAsset)
            .build();

        // Summary generator is built on-demand by cloning the per-asset generators, so the basis
        // selected on the builder must surface on the generated summary.
        let mut generator = TradingSummaryGenerator::init(
            Decimal::ZERO,
            DateTime::<Utc>::MIN_UTC,
            DateTime::<Utc>::MIN_UTC,
            &state.instruments,
            &state.assets,
        );

        assert_eq!(generator.generate(Annual365).basis, BalanceBasis::NetAsset);
    }
}