Skip to content

rules.kernel

The prop-firm rule engine — trailing MLL, daily loss limit, consistency — enforced in real time, not scored afterwards.

kernel

The Topstep Combine rule kernel — the canonical combine-rulebook state machine.

A PURE state machine (docs/topstep-rules.md §§1-5): no I/O, no clock reads, no randomness. Callers push int-ns UTC timestamps and exact Decimal balances in; the kernel answers with breaches and a verdict.

The single biggest correctness item is the two-state trailing Maximum Loss Limit (§2):

  • State A — floor ratchet, END OF DAY ONLY: the floor starts at starting_balance - mll_buffer and ratchets up only on end-of-day CLOSED balance, never intraday, never down. Once the ratcheted floor would reach the starting balance it locks there permanently.
  • State B — breach check, REAL TIME: every tick the caller pushes live equity (realized + unrealized) into :meth:CombineKernel.check_equity; touching the floor (equity <= floor) fails the account immediately (terminal).

The optional Daily Loss Limit (§3) is a same-day lockout, not a violation: tripping it returns a :class:Breach of kind DLL and sets day_locked until the next session close. An MLL breach takes precedence when both trip on the same tick. The stored breach property holds only the terminal MLL breach; DLL breaches are returned to the caller but never stored.

Breach limit semantics: for MLL it is the floor; for DLL it is the equity threshold day_start_balance - dll (the level at which the day locks).

Pass evaluation (§§1, 4) happens only in :meth:CombineKernel.on_session_close and only while IN_PROGRESS: the closed balance must reach starting_balance + profit_target, total profit must be positive, and the best traded day must satisfy best_day <= consistency_pct * total_profit (Topstep's target inflation: a too-big best day forces more total profit, implying the ~2-day minimum). best_day only considers days on which :meth:CombineKernel.on_trade_activity was called; losing days never reset it.

Verdict

Bases: IntEnum

Combine outcome. Transitions only IN_PROGRESS -> {PASSED, FAILED}.

IN_PROGRESS class-attribute instance-attribute

IN_PROGRESS = 0

PASSED class-attribute instance-attribute

PASSED = 1

FAILED class-attribute instance-attribute

FAILED = 2

BreachKind

Bases: IntEnum

Which limit was breached.

MLL class-attribute instance-attribute

MLL = 1

DLL class-attribute instance-attribute

DLL = 2

Breach

Bases: Struct

A limit breach at ts_ns. limit is the equity threshold breached:

the trailing floor for MLL, day_start_balance - dll for DLL.

kind instance-attribute

kind: BreachKind

ts_ns instance-attribute

ts_ns: int

equity instance-attribute

equity: Decimal

limit instance-attribute

limit: Decimal

DayRecord

Bases: Struct

One closed trading day. floor_after is the MLL floor in effect after

this close's end-of-day ratchet; had_trade records whether trade activity occurred during the day.

day instance-attribute

day: date

eod_balance instance-attribute

eod_balance: Decimal

day_pnl instance-attribute

day_pnl: Decimal

floor_after instance-attribute

floor_after: Decimal

had_trade instance-attribute

had_trade: bool

CombineKernel

CombineKernel(params: CombineParams)

The single canonical implementation of the Topstep Combine rulebook.

Drive it with three calls: :meth:on_trade_activity when a fill happens, :meth:check_equity on every tick with live equity (realized + unrealized), and :meth:on_session_close at the 17:00 ET Globex close with the day's closed balance. Once the verdict is terminal (PASSED or FAILED) all mutating calls become no-ops.

Source code in src/topstep_backtest/rules/kernel.py
def __init__(self, params: CombineParams) -> None:
    self._params = params
    self._floor = params.starting_balance - params.mll_buffer
    self._peak_eod = params.starting_balance
    self._locked = False
    self._verdict = Verdict.IN_PROGRESS
    self._breach: Breach | None = None
    self._best_day = _ZERO
    self._days_traded = 0
    self._day_records: list[DayRecord] = []
    self._day_start_balance = params.starting_balance
    self._last_closed_balance = params.starting_balance
    self._had_trade = False
    self._day_locked = False

params property

params: CombineParams

floor property

floor: Decimal

The current MLL floor (equity at/below this level is a fail).

locked property

locked: bool

Whether the floor has permanently locked at the starting balance.

verdict property

verdict: Verdict

best_day property

best_day: Decimal

Largest day_pnl over traded days so far (never below zero).

total_profit property

total_profit: Decimal

Last end-of-day closed balance minus the starting balance.

days_traded property

days_traded: int

Number of closed days on which trade activity occurred.

day_records property

day_records: tuple[DayRecord, ...]

breach property

breach: Breach | None

The terminal MLL breach, if the combine has failed.

day_start_balance property

day_start_balance: Decimal

Balance at the current trading day's start (prior session's close).

day_locked property

day_locked: bool

Whether the DLL has locked out trading for the rest of the day.

on_trade_activity

on_trade_activity(ts_ns: int) -> None

Mark that a trade happened; makes the current day a traded day.

Source code in src/topstep_backtest/rules/kernel.py
def on_trade_activity(self, ts_ns: int) -> None:
    """Mark that a trade happened; makes the current day a traded day."""
    if self._verdict is not Verdict.IN_PROGRESS:
        return
    self._had_trade = True

check_equity

check_equity(ts_ns: int, equity: Decimal) -> Breach | None

Real-time breach check on live equity (realized + unrealized).

Touching the MLL floor (equity <= floor) fails the account terminally and returns (and stores) the MLL breach. Otherwise, with a DLL configured, a day loss at/past the limit returns a DLL breach and sets day_locked (not terminal; MLL takes precedence when both trip). Once terminal, returns the stored breach without mutating.

Source code in src/topstep_backtest/rules/kernel.py
def check_equity(self, ts_ns: int, equity: Decimal) -> Breach | None:
    """Real-time breach check on live equity (realized + unrealized).

    Touching the MLL floor (``equity <= floor``) fails the account
    terminally and returns (and stores) the MLL breach. Otherwise, with a
    DLL configured, a day loss at/past the limit returns a DLL breach and
    sets ``day_locked`` (not terminal; MLL takes precedence when both
    trip). Once terminal, returns the stored breach without mutating.
    """
    if self._verdict is not Verdict.IN_PROGRESS:
        return self._breach
    if equity <= self._floor:
        breach = Breach(kind=BreachKind.MLL, ts_ns=ts_ns, equity=equity, limit=self._floor)
        self._breach = breach
        self._verdict = Verdict.FAILED
        return breach
    dll = self._params.dll
    if dll is not None and not self._day_locked:
        dll_floor = self._day_start_balance - dll
        if equity <= dll_floor:
            self._day_locked = True
            return Breach(kind=BreachKind.DLL, ts_ns=ts_ns, equity=equity, limit=dll_floor)
    return None

on_session_close

on_session_close(ts_ns: int, closed_balance: Decimal) -> None

Close the trading day at closed_balance (the 17:00 ET snapshot).

Closes the day's record first (day_pnl against the day's starting balance), then applies the end-of-day floor ratchet, then evaluates the pass condition, then rolls day state (DLL lockout clears; the next day's starting balance becomes closed_balance). No-op once the verdict is terminal.

Source code in src/topstep_backtest/rules/kernel.py
def on_session_close(self, ts_ns: int, closed_balance: Decimal) -> None:
    """Close the trading day at ``closed_balance`` (the 17:00 ET snapshot).

    Closes the day's record first (``day_pnl`` against the day's starting
    balance), then applies the end-of-day floor ratchet, then evaluates
    the pass condition, then rolls day state (DLL lockout clears; the next
    day's starting balance becomes ``closed_balance``). No-op once the
    verdict is terminal.
    """
    if self._verdict is not Verdict.IN_PROGRESS:
        return
    params = self._params
    if closed_balance <= self._floor:
        # A closed balance at/below the floor is an MLL breach even if no
        # intraday check ever observed it (e.g. flatten fees dropped the
        # balance after the last equity check of the day).
        self._breach = Breach(
            kind=BreachKind.MLL, ts_ns=ts_ns, equity=closed_balance, limit=self._floor
        )
        self._verdict = Verdict.FAILED
        return
    day_pnl = closed_balance - self._day_start_balance
    had_trade = self._had_trade
    if had_trade:
        self._days_traded += 1
        if day_pnl > self._best_day:
            self._best_day = day_pnl
    # End-of-day ratchet: closed balance only, never intraday, never down.
    if not self._locked and closed_balance > self._peak_eod:
        self._peak_eod = closed_balance
        new_floor = closed_balance - params.mll_buffer
        if new_floor >= params.starting_balance:
            self._floor = params.starting_balance
            self._locked = True  # permanent
        else:
            self._floor = new_floor
    self._day_records.append(
        DayRecord(
            day=trading_day_of(ts_ns),
            eod_balance=closed_balance,
            day_pnl=day_pnl,
            floor_after=self._floor,
            had_trade=had_trade,
        )
    )
    self._last_closed_balance = closed_balance
    total_profit = closed_balance - params.starting_balance
    if (
        closed_balance >= params.starting_balance + params.profit_target
        and total_profit > _ZERO
        and self._best_day <= params.consistency_pct * total_profit
    ):
        self._verdict = Verdict.PASSED
    # Roll to the next trading day.
    self._day_start_balance = closed_balance
    self._had_trade = False
    self._day_locked = False

max_position_micro_units

max_position_micro_units() -> int

The fixed position cap in micro-units for the whole Combine.

Source code in src/topstep_backtest/rules/kernel.py
def max_position_micro_units(self) -> int:
    """The fixed position cap in micro-units for the whole Combine."""
    return self._params.max_cap_micro_units