Skip to content

Simulator

The fundcloud.sim module is the execution engine: a single Simulator class with four entry points (run_strategy, run_weights, run_signals, run_orders), plus the small protocol classes that govern costs (FixedBps, PerShare, NoCost), slippage (HalfSpread, NoSlippage), and fill timing (NextBarOpen, SameBarClose). All paths return a SimResult that carries the post-run Portfolio, executed Trades, full Order history, and per-bar equity curve. See the Simulator guide for when to pick each entry point.

fundcloud.sim

The simulator engine.

Simulator and SimResult are lazy-loaded to avoid a circular import against fundcloud.strategies — strategies need Order from this package and the simulator needs BaseStrategy from fundcloud.strategies.

OrderSide module-attribute

OrderSide = Literal['buy', 'sell']

OrderKind module-attribute

OrderKind = Literal['market', 'limit']

Simulator

Simulator(
    data: Backend | DataFrame,
    *,
    costs: CostModel | None = None,
    slippage: SlippageModel | None = None,
    cash: float = 1000000.0,
    execution: ExecutionModel | None = None,
)

Discrete-time backtest engine.

Parameters:

Name Type Description Default
data Backend | DataFrame

A :class:~fundcloud.data.Backend (e.g. YF, CSV, Parquet) or a raw Bars DataFrame with (field, symbol) MultiIndex columns.

required
costs CostModel | None

Swap-in friction/execution models. Defaults: :class:FixedBps (5 bps), :class:NoSlippage, :class:NextBarOpen.

None
slippage CostModel | None

Swap-in friction/execution models. Defaults: :class:FixedBps (5 bps), :class:NoSlippage, :class:NextBarOpen.

None
execution CostModel | None

Swap-in friction/execution models. Defaults: :class:FixedBps (5 bps), :class:NoSlippage, :class:NextBarOpen.

None
cash float

Starting cash balance.

1000000.0

Examples:

Drive a weekly DCA on a synthetic single-asset Bars frame:

>>> import pandas as pd
>>> from fundcloud.sim import Simulator
>>> from fundcloud.strategies import DCA
>>> bars = pd.DataFrame({  # tiny, flat-price dummy
...     ("open", "SPY"): 100.0,
...     ("high", "SPY"): 100.0,
...     ("low", "SPY"): 100.0,
...     ("close", "SPY"): 100.0,
...     ("volume", "SPY"): 1_000.0,
... }, index=pd.date_range("2024-01-02", periods=60, freq="B"))
>>> bars.columns = pd.MultiIndex.from_tuples(bars.columns)
>>> result = Simulator(bars, cash=10_000.0).run_strategy(
...     DCA(100.0, horizon="weekly", weights={"SPY": 1.0}),
... )
>>> result.equity_curve.iloc[-1] >= 0.0
True
Source code in python/fundcloud/sim/simulator.py
def __init__(
    self,
    data: Backend | pd.DataFrame,
    *,
    costs: CostModel | None = None,
    slippage: SlippageModel | None = None,
    cash: float = 1_000_000.0,
    execution: ExecutionModel | None = None,
) -> None:
    self.bars: pd.DataFrame = _resolve_bars(data)
    self.costs: CostModel = costs if costs is not None else FixedBps(5.0)
    self.slippage: SlippageModel = slippage if slippage is not None else NoSlippage()
    self.cash: float = float(cash)
    self.execution: ExecutionModel = execution if execution is not None else NextBarOpen()

run_orders

run_orders(orders: DataFrame) -> SimResult

Execute an explicit long-format orders DataFrame.

Source code in python/fundcloud/sim/simulator.py
def run_orders(self, orders: pd.DataFrame) -> SimResult:
    """Execute an explicit long-format orders DataFrame."""
    required = {"ts", "asset", "side", "qty"}
    missing = required - set(orders.columns)
    if missing:
        msg = f"orders frame missing columns: {missing}"
        raise KeyError(msg)
    cfg = _model_tags(self.costs, self.slippage, self.execution)
    if cfg is not None:
        return self._run_orders_fast(orders, cfg)
    by_ts: dict[pd.Timestamp, list[Order]] = {}
    for row in orders.itertuples(index=False):
        ts = pd.Timestamp(row.ts)
        by_ts.setdefault(ts, []).append(
            Order(ts=ts, asset=str(row.asset), side=str(row.side), qty=float(row.qty))
        )

    def _orders_for(ctx: Context) -> list[Order]:
        return by_ts.get(ctx.ts, [])

    portfolio = self._new_portfolio()
    pending: list[tuple[int, Order]] = []
    return self._drive(portfolio, pending, per_bar_orders=_orders_for)

run_signals

run_signals(
    entries: DataFrame,
    exits: DataFrame,
    *,
    size: float = 1.0,
) -> SimResult

Convert boolean entry/exit panels into market orders.

size is a fraction of current cash to allocate per entry.

Source code in python/fundcloud/sim/simulator.py
def run_signals(
    self,
    entries: pd.DataFrame,
    exits: pd.DataFrame,
    *,
    size: float = 1.0,
) -> SimResult:
    """Convert boolean entry/exit panels into market orders.

    ``size`` is a fraction of current cash to allocate per entry.
    """
    cfg = _model_tags(self.costs, self.slippage, self.execution)
    if cfg is not None:
        return self._run_signals_fast(entries, exits, size, cfg)

    en = entries.reindex(index=self.bars.index).fillna(False).astype(bool)
    ex = exits.reindex(index=self.bars.index).fillna(False).astype(bool)

    def _orders_for(ctx: Context) -> list[Order]:
        orders: list[Order] = []
        for asset in en.columns:
            if ctx.ts in en.index and en.loc[ctx.ts, asset]:
                prices = _current_prices_map(ctx)
                px = prices.get(asset)
                if px is None or px <= 0:
                    continue
                qty = max((ctx.portfolio.cash * size) / px, 0.0)
                if qty > 0:
                    orders.append(Order(ts=ctx.ts, asset=asset, side="buy", qty=qty))
        for asset in ex.columns:
            if ctx.ts in ex.index and ex.loc[ctx.ts, asset]:
                pos = ctx.portfolio._live.positions.get(asset)
                if pos is not None and pos.qty > 0:
                    orders.append(Order(ts=ctx.ts, asset=asset, side="sell", qty=pos.qty))
        return orders

    portfolio = self._new_portfolio()
    pending: list[tuple[int, Order]] = []
    return self._drive(portfolio, pending, per_bar_orders=_orders_for)

run_strategy

run_strategy(strategy: BaseStrategy) -> SimResult

Drive a :class:BaseStrategy bar by bar and return a :class:SimResult.

The strategy sees one :class:~fundcloud.strategies.base.Context per bar and returns zero or more :class:~fundcloud.sim.Order instances. The simulator applies the execution / cost / slippage models and updates a single live :class:~fundcloud.portfolio.Portfolio.

Source code in python/fundcloud/sim/simulator.py
def run_strategy(self, strategy: BaseStrategy) -> SimResult:
    """Drive a :class:`BaseStrategy` bar by bar and return a :class:`SimResult`.

    The strategy sees one :class:`~fundcloud.strategies.base.Context` per
    bar and returns zero or more :class:`~fundcloud.sim.Order` instances.
    The simulator applies the execution / cost / slippage models and
    updates a single live :class:`~fundcloud.portfolio.Portfolio`.
    """
    portfolio = self._new_portfolio()
    strategy.init(self.bars, portfolio)
    pending: list[tuple[int, Order]] = []
    return self._drive(
        portfolio,
        pending,
        per_bar_orders=lambda ctx: strategy.decide(ctx),
        on_close=lambda: strategy.close(portfolio),
    )

run_weights

run_weights(target_weights: DataFrame) -> SimResult

At each row of target_weights, rebalance toward those weights.

target_weights is a dense DataFrame indexed by timestamp with one column per asset; missing values are forward-filled.

Source code in python/fundcloud/sim/simulator.py
def run_weights(self, target_weights: pd.DataFrame) -> SimResult:
    """At each row of ``target_weights``, rebalance toward those weights.

    ``target_weights`` is a dense ``DataFrame`` indexed by timestamp with
    one column per asset; missing values are forward-filled.
    """
    cfg = _model_tags(self.costs, self.slippage, self.execution)
    if cfg is not None:
        return self._run_weights_fast(target_weights, cfg)

    aligned = target_weights.reindex(index=self.bars.index).ffill()
    portfolio = self._new_portfolio()

    def _orders_for(ctx: Context) -> list[Order]:
        if ctx.ts not in target_weights.index:
            return []
        weights = aligned.loc[ctx.ts].dropna().to_dict()
        if not weights:
            return []
        # Use the Hold helper's core logic, without the "first-bar" gate.
        from fundcloud.strategies.hold import _orders_to_reach_weights

        return _orders_to_reach_weights(ctx, weights)

    pending: list[tuple[int, Order]] = []
    return self._drive(portfolio, pending, per_bar_orders=_orders_for)

SimResult dataclass

SimResult(
    portfolio: Portfolio,
    trades: DataFrame,
    orders: DataFrame,
    equity_curve: Series,
    bars: DataFrame,
)

Output of :meth:Simulator.run_*.

Examples:

>>> # Given ``result = Simulator(bars).run_strategy(strategy)`` —
>>> # ``result.pf`` is a shortcut for ``result.portfolio``:
>>> # result.pf.sharpe()
>>> # result.pf.max_drawdown()

pf property

pf: Portfolio

Shortcut: result.pf is the same object as result.portfolio.

Saves typing in interactive sessions where chains like result.pf.sharpe() or result.pf.metrics() are common.

metrics

metrics() -> pd.Series

Full ~55-metric bundle — delegates to :meth:Portfolio.metrics.

Source code in python/fundcloud/sim/simulator.py
def metrics(self) -> pd.Series:
    """Full ~55-metric bundle — delegates to :meth:`Portfolio.metrics`."""
    return self.portfolio.metrics()

summary

summary() -> pd.Series

Compact 11-metric view — delegates to :meth:Portfolio.summary.

Source code in python/fundcloud/sim/simulator.py
def summary(self) -> pd.Series:
    """Compact 11-metric view — delegates to :meth:`Portfolio.summary`."""
    return self.portfolio.summary()

Order dataclass

Order(
    ts: Timestamp,
    asset: str,
    side: OrderSide,
    qty: float | None = None,
    notional: float | None = None,
    kind: OrderKind = "market",
    limit_price: float | None = None,
)

Instruction to trade. Frozen so strategies can hand one up safely.

Exactly one of qty or notional must be set (qty wins if both are). qty is positive for buys and negative for sells — the :attr:side field is redundant for market orders but makes limit logic and audit trails clearer.

signed_qty

signed_qty() -> float

Directional qty: positive for buys, negative for sells.

Source code in python/fundcloud/sim/orders.py
def signed_qty(self) -> float:
    """Directional qty: positive for buys, negative for sells."""
    if self.qty is None:
        raise ValueError("signed_qty requires a resolved qty")
    return self.qty if self.side == "buy" else -self.qty

with_qty

with_qty(qty: float) -> Order

Return a new Order with qty set and notional cleared.

Source code in python/fundcloud/sim/orders.py
def with_qty(self, qty: float) -> Order:
    """Return a new Order with ``qty`` set and ``notional`` cleared."""
    return Order(
        ts=self.ts,
        asset=self.asset,
        side=self.side,
        qty=qty,
        notional=None,
        kind=self.kind,
        limit_price=self.limit_price,
    )

Trade dataclass

Trade(
    order: Order,
    ts: Timestamp,
    asset: str,
    qty: float,
    price: float,
    fee: float = 0.0,
    slippage_bps: float = 0.0,
)

A filled :class:Order. The portfolio applies these to mutate state.

Attributes:

Name Type Description
order Order

The original :class:Order that produced this fill.

ts Timestamp

Timestamp at which the fill executed.

asset str

Asset being traded (mirrors order.asset for convenience).

qty float

Signed quantity. Positive for buys, negative for sells.

price float

Fill price after slippage is applied.

fee float

Commission / exchange fee charged to cash.

slippage_bps float

Slippage applied vs the reference price, in basis points.

CostModel

Bases: Protocol

Fee charged to cash for a fill.

FixedBps dataclass

FixedBps(bps: float = 5.0, minimum: float = 0.0)

Proportional-to-notional fee, in basis points (1 bp = 0.01 %).

Minimum fee can be set via minimum.

PerShare dataclass

PerShare(rate: float = 0.005, minimum: float = 1.0)

Flat per-share commission (typical US equity broker pricing).

NoCost dataclass

NoCost()

Zero-cost model for tests and textbook examples.

SlippageModel

Bases: Protocol

Adjust the raw reference price into an achievable fill price.

apply

apply(
    *, price: float, side: Literal["buy", "sell"]
) -> tuple[float, float]

Return (fill_price, slippage_bps).

Source code in python/fundcloud/sim/slippage.py
def apply(self, *, price: float, side: Literal["buy", "sell"]) -> tuple[float, float]:
    """Return ``(fill_price, slippage_bps)``."""

HalfSpread dataclass

HalfSpread(spread_bps: float = 2.0)

Pay half the bid-ask spread (quoted in basis points) on every fill.

NoSlippage dataclass

NoSlippage()

ExecutionModel

Bases: Protocol

Decide when an order submitted at bar t fills.

NextBarOpen dataclass

NextBarOpen()

Orders submitted at bar t fill at the open of bar t + 1.

SameBarClose dataclass

SameBarClose()

Orders submitted at bar t fill at the close of bar t.

Note the bias: signals derived from bar t can inspect the close then trade at the close. Use :class:NextBarOpen for honest backtests.