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, NextBarClose). 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']

Direction of an :class:Order.

"buy" opens or adds to a long (and closes / reduces a short). "sell" opens or adds to a short (and closes / reduces a long). The simulator routes the same side through both flat→position opens and position→opposite closes — what matters is the sign of the resulting position-delta, not whether the trader thinks of it as "entry" or "exit".

OrderKind module-attribute

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

Kind of order. "market" fills at the reference price; "limit" requires a limit_price.

TradeReason module-attribute

TradeReason = Literal[
    "signal", "stop_loss", "take_profit", "trailing_stop"
]

Why a :class:Trade was emitted.

"signal" — a strategy-emitted :class:Order that filled normally. "stop_loss" — a forced exit synthesised by the simulator's intra-bar stop-loss check (long stop tripped on bar low, short stop on bar high). "take_profit" — a forced exit synthesised by the simulator's intra-bar take-profit check (long TP tripped on bar high, short TP on bar low). "trailing_stop" — a forced exit synthesised by the simulator's intra-bar trailing-stop check (the trail level ratchets in the favourable direction with each bar's high/low, then triggers like a fixed stop on the unfavourable side).

When several stops could fire on the same bar, the conservative arbitration is: any stop (fixed or trailing) beats take-profit; among sl_stop and tsl_stop the tighter effective level wins (the one that's closer to current price). The simulator records the source so analytics can split realised P&L between discretionary exits, defensive stop-outs, profit-taking, and trail-following.

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.

Optional columns sl_stop / tp_stop attach intra-bar stop-loss / take-profit fractions to each order. They are honoured by the dispatcher (Python fallback today; Rust kernel once parity lands).

Source code in python/fundcloud/sim/simulator.py
def run_orders(self, orders: pd.DataFrame) -> SimResult:
    """Execute an explicit long-format orders DataFrame.

    Optional columns ``sl_stop`` / ``tp_stop`` attach intra-bar
    stop-loss / take-profit fractions to each order. They are
    honoured by the dispatcher (Python fallback today; Rust kernel
    once parity lands).
    """
    required = {"ts", "asset", "side", "qty"}
    missing = required - set(orders.columns)
    if missing:
        msg = f"orders frame missing columns: {missing}"
        raise KeyError(msg)
    # Bracket-fraction validation runs at the entry point so the fast
    # (kernel) and slow (``Order(...)``) paths agree on what's a valid
    # input. The slow path also re-validates via ``Order.__post_init__``.
    _validate_orders_brackets(orders)
    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)
        sl = getattr(row, "sl_stop", None)
        tp = getattr(row, "tp_stop", None)
        tsl = getattr(row, "tsl_stop", None)
        sl = float(sl) if sl is not None and pd.notna(sl) and float(sl) > 0 else None
        tp = float(tp) if tp is not None and pd.notna(tp) and float(tp) > 0 else None
        tsl = float(tsl) if tsl is not None and pd.notna(tsl) and float(tsl) > 0 else None
        # Forward optional sizing fields so the slow path produces the
        # same Order shape as the fast kernel — a notional-only row
        # used to silently lose its ``notional`` here, and any
        # ``kind="limit"`` / ``limit_price`` was dropped too.
        qty_raw = getattr(row, "qty", None)
        qty = float(qty_raw) if qty_raw is not None and pd.notna(qty_raw) else None
        notional_raw = getattr(row, "notional", None)
        notional = (
            float(notional_raw) if notional_raw is not None and pd.notna(notional_raw) else None
        )
        kind_raw = getattr(row, "kind", None)
        kind = str(kind_raw) if kind_raw is not None and pd.notna(kind_raw) else "market"
        limit_raw = getattr(row, "limit_price", None)
        limit_price = (
            float(limit_raw) if limit_raw is not None and pd.notna(limit_raw) else None
        )
        by_ts.setdefault(ts, []).append(
            Order(
                ts=ts,
                asset=str(row.asset),
                side=str(row.side),
                qty=qty,
                notional=notional,
                kind=kind,  # type: ignore[arg-type]
                limit_price=limit_price,
                sl_stop=sl,
                tp_stop=tp,
                tsl_stop=tsl,
            )
        )

    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.

.. note:: Signal panels emit market orders without attached brackets. For intra-bar stop-loss / take-profit support, drive the same logic through :meth:run_strategy with a custom :class:BaseStrategy that emits Order(..., sl_stop=..., tp_stop=...).

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.

    .. note::
       Signal panels emit market orders without attached brackets.
       For intra-bar stop-loss / take-profit support, drive the
       same logic through :meth:`run_strategy` with a custom
       :class:`BaseStrategy` that emits ``Order(..., sl_stop=...,
       tp_stop=...)``.
    """
    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,
    sl_stop: float | None = None,
    tp_stop: float | None = None,
    tsl_stop: 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 unsigned; the :attr:side field carries direction. The :class:~fundcloud.sim.Simulator resolves notional-only orders to a quantity at fill time using the reference price.

Parameters:

Name Type Description Default
ts Timestamp

Timestamp at which the strategy emitted the order.

required
asset str

Asset ticker.

required
side OrderSide

"buy" (long) or "sell" (short). Not the same as "open" / "close" — a buy on top of a short reduces the short position; a sell on top of a long reduces the long. See :data:OrderSide for the full convention.

required
qty float | None

Unsigned share count. Mutually exclusive with notional.

None
notional float | None

Unsigned dollar amount; the simulator divides by the fill price at execution time. Mutually exclusive with qty.

None
kind OrderKind

"market" (default) or "limit". Limit orders also need limit_price.

'market'
limit_price float | None

Price ceiling (buy) or floor (sell). Required when kind="limit".

None
sl_stop float | None

Stop-loss attached to the entry, expressed as a fraction in (0, 1) of the fill price (e.g. 0.10 = 10%). On a long entry the simulator records sl_level = fill_price * (1 - sl_stop) and synthesises a forced sell when a subsequent bar's low pierces it. On a short entry the level is fill_price * (1 + sl_stop) tested against bar high. Anchored to the latest fill — an accumulating second buy at a higher price tightens the stop. Cleared when the position closes. See :attr:fundcloud.portfolio.Position.sl_level and :data:~fundcloud.sim.TradeReason.

None
tp_stop float | None

Take-profit attached to the entry, expressed as a fraction > 0 of the fill price (e.g. 0.20 = 20%). Long entries get tp_level = fill_price * (1 + tp_stop) tested against bar high; shorts get fill_price * (1 - tp_stop) tested against bar low. No upper bound — but values >= 1 on a short never fire because price cannot drop more than 100%. Anchor and clear rules mirror sl_stop. May be set together with sl_stop as a bracket order; if both could fire on the same bar, the stop-loss wins. See :attr:fundcloud.portfolio.Position.tp_level.

None
tsl_stop float | None

Trailing stop-loss attached to the entry, expressed as a fraction in (0, 1) of the high-water mark (e.g. 0.05 = 5%). Unlike sl_stop, the trail anchor ratchets in the favourable direction: long anchors track max(anchor, bar.high) bar by bar, never moving down; shorts track min(anchor, bar.low), never moving up. Effective trail level = anchor * (1 - tsl_stop) for long, anchor * (1 + tsl_stop) for short. May coexist with sl_stop (the tighter fill wins — higher price for long, lower for short) and tp_stop (stops still beat take-profit on tied bars).

On accumulating entries, the existing trail is retained — the high-water mark continues ratcheting from the first entry's price, regardless of the new entry's fill price or tsl_stop value. The trail is unconditionally cleared on close.

Forced exits tag :attr:Trade.reason as "trailing_stop". See :attr:fundcloud.portfolio.Position.tsl_pct / :attr:fundcloud.portfolio.Position.tsl_anchor.

None

Raises:

Type Description
ValueError

If neither qty nor notional is set; if qty is zero; if kind="limit" without a limit_price; if sl_stop is outside (0, 1); if tp_stop is non-positive; or if tsl_stop is outside (0, 1).

Examples:

>>> import pandas as pd
>>> from fundcloud.sim import Order
>>> Order(ts=pd.Timestamp("2024-01-02"), asset="SPY", side="buy", qty=10.0)
Order(ts=Timestamp('2024-01-02 00:00:00'), asset='SPY', side='buy', qty=10.0, ...)

Bracket order — long with 5% stop-loss and 10% take-profit:

>>> Order(
...     ts=pd.Timestamp("2024-01-02"), asset="SPY", side="buy",
...     qty=10.0, sl_stop=0.05, tp_stop=0.10,
... )
Order(ts=..., sl_stop=0.05, tp_stop=0.1, ...)

Full bracket — fixed stop-loss, take-profit, and trailing stop on the same entry. The fixed SL caps the worst-case loss at entry, the take-profit caps the best-case gain, and the trail rides the middle:

>>> Order(
...     ts=pd.Timestamp("2024-01-02"), asset="SPY", side="buy",
...     qty=10.0, sl_stop=0.10, tp_stop=0.30, tsl_stop=0.05,
... )
Order(ts=..., sl_stop=0.1, tp_stop=0.3, tsl_stop=0.05)

signed_qty

signed_qty() -> float

Position-delta: positive for buys (long-bias), negative for sells (short-bias).

Returns the change this order applies to the asset's position when it fills — +qty for "buy", -qty for "sell". A short-cover order (buy on top of a short) still has a positive signed quantity; the resulting net position is what indicates a close.

Returns:

Type Description
float

+qty when side == "buy", -qty when side == "sell".

Raises:

Type Description
ValueError

If qty is unset (i.e. the order is still notional-only — resolve it first via :meth:with_qty).

Source code in python/fundcloud/sim/orders.py
def signed_qty(self) -> float:
    """Position-delta: positive for buys (long-bias), negative for sells (short-bias).

    Returns the change this order applies to the asset's position
    when it fills — ``+qty`` for ``"buy"``, ``-qty`` for ``"sell"``.
    A short-cover order (buy on top of a short) still has a positive
    signed quantity; the resulting net position is what indicates
    a close.

    Returns
    -------
    float
        ``+qty`` when ``side == "buy"``, ``-qty`` when ``side == "sell"``.

    Raises
    ------
    ValueError
        If ``qty`` is unset (i.e. the order is still notional-only —
        resolve it first via :meth:`with_qty`).
    """
    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 :class:Order with qty set and notional cleared.

Useful when the simulator resolves a notional-only order to an explicit share count using the fill price.

Parameters:

Name Type Description Default
qty float

Unsigned share count for the resolved order.

required

Returns:

Type Description
Order

Copy of self with qty=qty and notional=None.

Source code in python/fundcloud/sim/orders.py
def with_qty(self, qty: float) -> Order:
    """Return a new :class:`Order` with ``qty`` set and ``notional`` cleared.

    Useful when the simulator resolves a notional-only order to an
    explicit share count using the fill price.

    Parameters
    ----------
    qty
        Unsigned share count for the resolved order.

    Returns
    -------
    Order
        Copy of ``self`` with ``qty=qty`` and ``notional=None``.
    """
    return Order(
        ts=self.ts,
        asset=self.asset,
        side=self.side,
        qty=qty,
        notional=None,
        kind=self.kind,
        limit_price=self.limit_price,
        sl_stop=self.sl_stop,
        tp_stop=self.tp_stop,
        tsl_stop=self.tsl_stop,
    )

Trade dataclass

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

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

Trades are the simulator's output unit: each one books a quantity of an asset at a fill price, charges a fee against cash, and records the slippage applied vs the reference price.

Attributes:

Name Type Description
order Order

The original :class:~fundcloud.sim.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. Always non-negative.

slippage_bps float

Slippage applied vs the reference price, in basis points (positive number). 0.0 under :class:~fundcloud.sim.NoSlippage.

reason TradeReason

Why the trade fired — see :data:TradeReason for the full enumeration. "signal" (default) for strategy-driven fills; "stop_loss" / "take_profit" / "trailing_stop" for forced exits synthesised by the simulator's intra-bar bracket check. Surfaces in the trades DataFrame so analytics can split realised P&L between discretionary exits, defensive stop-outs, profit-taking, and trail-following.

notional property

notional: float

Signed dollar value of the fill: qty * price.

Positive for buys (cash outflow), negative for sells (cash inflow). Note this excludes fees — the simulator subtracts fee from cash separately.

CostModel

Bases: Protocol

Fee charged to cash for a fill.

Implement fee(price, qty) returning a non-negative dollar amount. The simulator subtracts the result from cash on every fill.

fee

fee(*, price: float, qty: float) -> float

Return the commission for a fill of qty shares at price.

Parameters:

Name Type Description Default
price float

Fill price (after slippage).

required
qty float

Signed quantity. Implementations should treat the magnitude; buys and sells are charged symmetrically.

required

Returns:

Type Description
float

Non-negative dollar fee.

Source code in python/fundcloud/sim/costs.py
def fee(self, *, price: float, qty: float) -> float:
    """Return the commission for a fill of ``qty`` shares at ``price``.

    Parameters
    ----------
    price
        Fill price (after slippage).
    qty
        Signed quantity. Implementations should treat the magnitude;
        buys and sells are charged symmetrically.

    Returns
    -------
    float
        Non-negative dollar fee.
    """
    ...

FixedBps dataclass

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

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

The :class:~fundcloud.sim.Simulator default. Charges max(minimum, |price * qty| * bps * 1e-4) per fill, so a 5 bps model on a $10,000 trade costs $5.

Parameters:

Name Type Description Default
bps float

Basis points charged on notional. Default 5.0.

5.0
minimum float

Floor in dollars. Useful when modelling broker minimums. Default 0.0.

0.0

Examples:

>>> from fundcloud.sim import FixedBps
>>> FixedBps(bps=10).fee(price=100.0, qty=50.0)
5.0
>>> FixedBps(bps=5, minimum=1.0).fee(price=10.0, qty=1.0)  # tiny trade hits the floor
1.0

PerShare dataclass

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

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

Charges max(minimum, |qty| * rate) per fill — independent of price.

Parameters:

Name Type Description Default
rate float

Dollars per share. Default 0.005 (half a cent).

0.005
minimum float

Floor in dollars. Default 1.0.

1.0

Examples:

>>> from fundcloud.sim import PerShare
>>> PerShare(rate=0.005).fee(price=50.0, qty=400.0)
2.0
>>> PerShare(rate=0.005, minimum=1.0).fee(price=50.0, qty=10.0)  # below floor
1.0

NoCost dataclass

NoCost()

Zero-cost model for tests and textbook examples.

Examples:

>>> from fundcloud.sim import NoCost
>>> NoCost().fee(price=100.0, qty=10.0)
0.0

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).

Parameters:

Name Type Description Default
price float

Reference price from the :class:~fundcloud.sim.ExecutionModel (typically the bar open or close).

required
side Literal['buy', 'sell']

"buy" or "sell". Buys should generally fill above price, sells below.

required

Returns:

Type Description
tuple[float, float]

(fill_price, slippage_bps) — the adjusted price and the implied slippage in basis points (positive number, recorded on the :class:~fundcloud.sim.Trade for analytics).

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)``.

    Parameters
    ----------
    price
        Reference price from the :class:`~fundcloud.sim.ExecutionModel`
        (typically the bar open or close).
    side
        ``"buy"`` or ``"sell"``. Buys should generally fill *above*
        ``price``, sells *below*.

    Returns
    -------
    tuple[float, float]
        ``(fill_price, slippage_bps)`` — the adjusted price and the
        implied slippage in basis points (positive number, recorded
        on the :class:`~fundcloud.sim.Trade` for analytics).
    """
    ...

HalfSpread dataclass

HalfSpread(spread_bps: float = 2.0)

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

Buys execute at price * (1 + half_spread_bps * 1e-4); sells at the symmetric discount. The recorded slippage is half the spread.

Parameters:

Name Type Description Default
spread_bps float

Full bid-ask spread in basis points. Default 2.0 (i.e. half a bp paid each way) — a reasonable proxy for liquid US equities.

2.0

Examples:

>>> from fundcloud.sim import HalfSpread
>>> HalfSpread(spread_bps=10.0).apply(price=100.0, side="buy")
(100.05, 5.0)
>>> HalfSpread(spread_bps=10.0).apply(price=100.0, side="sell")
(99.95, 5.0)

NoSlippage dataclass

NoSlippage()

Fills hit the reference price exactly. The :class:Simulator default.

Examples:

>>> from fundcloud.sim import NoSlippage
>>> NoSlippage().apply(price=100.0, side="buy")
(100.0, 0.0)

ExecutionModel

Bases: Protocol

Decide when an order submitted at bar t fills.

fill_at

fill_at(
    *, signal_index: int, bars_index_size: int
) -> int | None

Resolve the bar index where an order fills.

Parameters:

Name Type Description Default
signal_index int

Integer position of the bar that emitted the order.

required
bars_index_size int

Total number of bars in the simulator's index.

required

Returns:

Type Description
int or None

Either None (no future bar available — typically the last bar; the simulator records the order as filled=False and skips execution) or a bar index strictly greater than signal_index. Same-bar or earlier fills are a contract violation: they introduce look-ahead bias and the simulator raises :class:ValueError.

Source code in python/fundcloud/sim/execution.py
def fill_at(
    self,
    *,
    signal_index: int,
    bars_index_size: int,
) -> int | None:
    """Resolve the bar index where an order fills.

    Parameters
    ----------
    signal_index
        Integer position of the bar that emitted the order.
    bars_index_size
        Total number of bars in the simulator's index.

    Returns
    -------
    int or None
        Either ``None`` (no future bar available — typically the
        last bar; the simulator records the order as
        ``filled=False`` and skips execution) or a bar index
        **strictly greater than** ``signal_index``. Same-bar or
        earlier fills are a contract violation: they introduce
        look-ahead bias and the simulator raises
        :class:`ValueError`.
    """
    ...

reference_price

reference_price(
    *, bars: DataFrame, fill_index: int, asset: str
) -> float

Return the reference price for a fill at fill_index.

The simulator hands this price to the :class:~fundcloud.sim.SlippageModel, which nudges it to the achievable fill price.

Parameters:

Name Type Description Default
bars DataFrame

Bars frame ((field, symbol) MultiIndex columns).

required
fill_index int

Bar index returned by :meth:fill_at.

required
asset str

Asset ticker.

required

Returns:

Type Description
float

Reference price (typically open or close of the fill bar).

Source code in python/fundcloud/sim/execution.py
def reference_price(
    self,
    *,
    bars: pd.DataFrame,
    fill_index: int,
    asset: str,
) -> float:
    """Return the reference price for a fill at ``fill_index``.

    The simulator hands this price to the
    :class:`~fundcloud.sim.SlippageModel`, which nudges it to the
    achievable fill price.

    Parameters
    ----------
    bars
        Bars frame (``(field, symbol)`` MultiIndex columns).
    fill_index
        Bar index returned by :meth:`fill_at`.
    asset
        Asset ticker.

    Returns
    -------
    float
        Reference price (typically open or close of the fill bar).
    """
    ...

NextBarOpen dataclass

NextBarOpen()

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

The honest default: a strategy that decides on bar t's close (using bar-t prices and history) gets the next available executable price, with no look-ahead. Orders emitted on the final bar can't fill — the simulator records them as filled=False.

Examples:

>>> from fundcloud.sim import NextBarOpen
>>> NextBarOpen().fill_at(signal_index=4, bars_index_size=10)
5
>>> NextBarOpen().fill_at(signal_index=9, bars_index_size=10) is None
True

NextBarClose dataclass

NextBarClose()

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

Like :class:NextBarOpen but uses the close of the fill bar as the reference price — convenient when modelling end-of-day desks or a full bar of participation between signal and execution. Look-ahead-free: the fill bar is strictly later than the signal bar. Orders emitted on the final bar can't fill — the simulator records them as filled=False.

Examples:

>>> from fundcloud.sim import NextBarClose
>>> NextBarClose().fill_at(signal_index=4, bars_index_size=10)
5
>>> NextBarClose().fill_at(signal_index=9, bars_index_size=10) is None
True