Keel Docs
Strategies

Entries, Exits and Positions

How Keel strategies enter and exit trades. A TradeManager turns entry signals into a Position, and each stop, target, trail, time exit, partial, add or re-entry gate is one rule on it.

Ask AI about Keel

Entries, Exits and Positions

Four layers, in one direction: signals (market only; entry and exit signals are built separately) → positions (TradeManager, the only layer that remembers what the strategy did) → sizing (per asset) → portfolio. Continuous forecasts skip the position layer.

Position layer: TradeManager turns entry signals into a Position; each rule is a reader, then ordinary components, then an action (Exit, Reduce, ScaleIn, AllowEntry).

A strategy that is either in a trade or out of it (an RSI dip buy, a breakout, a trend follower) builds its entry signal in the signal layer, stores it, and hands it to TradeManager. Everything that depends on the trade itself (a stop measured from the entry price, a target, a trailing stop, a time limit, taking half off, adding to a winner, waiting before the next trade) is a rule below it.

How a rule is written

Below TradeManager, each branch of a dict step is one rule. The branch name is the rule's name, and it is the exit reason you see in the trades table. A rule has three parts:

  1. A reader turns the Position into an ordinary series, for example TradeReturn() (this trade's return from its entry, as a fraction), BarsHeld() or DrawdownFromPeak().
  2. Ordinary components work on that series unchanged, for example BelowThresholdFilter(threshold=-0.05, inclusive=True).
  3. An action applies the result to the trade: Exit(), Reduce(fraction=), ScaleIn(units=, times=) or AllowEntry().

A market value enters a rule with Load('slot'): store it above TradeManager, then [Load('rsi_recovered'), Exit()] closes the trade when that mask is 1. Return thresholds are fractions: -0.05 is a 5% loss, and -5 would mean −500%.

After the rules, Exposure() turns the Position into the exposure that a sizer sizes. RiskSizer is the exception: it takes the Position directly and sizes each trade to the risk to its stop. Entry/exit strategies use Execution(rebalance='on_change').

A bracket: 5% stop, 10% target

# canonical: bracket
Globals(target_timeframe='4h')
Universe(mode='manual', symbols=['BTC', 'ETH', 'SOL'])
Execution(rebalance='on_change')
Pipeline([
    PriceDataLoader(),
    Store('ohlcv'),
    RSI(period=14),
    BelowThresholdFilter(threshold=30.0),
    Store('entries'),
    TradeManager(entries='entries', prices='ohlcv'),
    {
        'stop':   [TradeReturn(), BelowThresholdFilter(threshold=-0.05, inclusive=True), Exit()],
        'target': [TradeReturn(), AboveThresholdFilter(threshold=0.10, inclusive=True), Exit()],
    },
    Exposure(),
    EqualWeightSizer(),
])

The same two rules in their short forms: StopLoss(pct=0.05) and TakeProfit(pct=0.10), written as {'stop': [StopLoss(pct=0.05)], 'target': [TakeProfit(pct=0.10)]}.

Exit on a market signal

Buy RSI below 30 and hold until RSI recovers above 50. The exit is a market fact, so it is computed above TradeManager, stored, and loaded into the rule.

# canonical: market_exit
Globals(target_timeframe='4h')
Universe(mode='manual', symbols=['BTC', 'ETH', 'SOL'])
Execution(rebalance='on_change')
Pipeline([
    PriceDataLoader(),
    Store('ohlcv'),
    RSI(period=14),
    Store('rsi'),
    BelowThresholdFilter(threshold=30.0),
    Store('entries'),
    Load('rsi'),
    AboveThresholdFilter(threshold=50.0),
    Store('rsi_recovered'),
    TradeManager(entries='entries', prices='ohlcv'),
    {
        'rsi_recovered': [Load('rsi_recovered'), Exit()],
    },
    Exposure(),
    EqualWeightSizer(),
])

Readers

Trade readers read the open trade:

  • TradeReturn(): return from the entry close, a fraction, positive when winning on either side.
  • TradePnL(): profit per unit in price units; divide it by AtEntry(slot=) for an R multiple.
  • BarsHeld(): bars since this trade's entry (0 on the entry bar).
  • DrawdownFromPeak() (a fraction, at most 0) and GiveBack() (price units): how far the trade has come back from its best close.
  • MaxFavorable() / MaxAdverse(): the best and worst return reached so far.
  • EntryPrice(), AveragePrice(), PeakPrice(): price levels of the trade.
  • ReturnFromAverage() / ReturnSinceLastFill(): returns after adds, for DCA targets and safety orders.
  • UnitsHeld(), SoldFraction(), AddCount(): what the rules have done, as of the start of the bar. A rule that reads SoldFraction() or AddCount() goes in a later dict step than the rule that sells or adds.
  • AtEntry(slot=): a market value sampled at the entry bar and held for the trade (ATR at entry, the range low at entry).
  • TradeDirection(), IsLong(), IsShort(): the trade's side.

Between-trade readers decide when the next trade may open, and end in AllowEntry(): BarsSinceExit(), LastTradeReturn(), EntryCount(window=), LossStreak(window=), RealizedPnL(window=) and EntriesThisSignal(). Windows default to the UTC calendar day.

SinceEntry(agg='max') keeps a running max, min, sum or mean since the entry, for rules such as breakeven after the trade has reached +2R.

Actions

  • Exit() closes the whole trade on the first bar its 0/1 mask is 1.
  • Reduce(fraction=0.5) sells a fraction of the initial size, once per trade.
  • ScaleIn(units=1.0, times=4) adds units each time its mask turns on, up to times per trade; on='every_bar' adds on every bar the mask is on.
  • AllowEntry() lets a new trade open only on bars where its mask is 1, in addition to the default wait for the entry signal to reset.

Short forms

Each short form expands to exactly the rule written out above it:

  • StopLoss(pct=0.05): [TradeReturn(), BelowThresholdFilter(threshold=-0.05, inclusive=True), Exit()].
  • TakeProfit(pct=0.10): [TradeReturn(), AboveThresholdFilter(threshold=0.10, inclusive=True), Exit()]; with fraction= it ends in Reduce instead.
  • TrailingStop(pct=0.08): [DrawdownFromPeak(), BelowThresholdFilter(threshold=-0.08), Exit()]. TrailingStop(atr=2.5, period=14) compares GiveBack() with 2.5 × ATR.
  • MaxHold(bars=48) or MaxHold(window='2d'): [BarsHeld(), AboveThresholdFilter(threshold=48, inclusive=True), Exit()].
  • Cooldown(bars=4) or Cooldown(window='2h'): [BarsSinceExit(), AboveThresholdFilter(threshold=4), AllowEntry()].

The defaults

  • After an exit, the next trade waits for the entry signal to reset (turn off, then on again). Every AllowEntry() gate adds to that wait. TradeManager(reentry='any_bar') drops it.
  • Rules start the bar after the entry.
  • Readers of what the rules did (SoldFraction(), AddCount(), UnitsHeld()) report the state as of the start of the bar.
  • On one bar, a full exit comes first, then partials, then adds.
  • An opposite entry signal closes the trade and opens one the other way.
  • A market exit's bar also refuses a new entry on that bar.
  • The most units a trade can hold is derived from its ScaleIn rules, and the validator states it.
  • On a bar with no price, nothing happens.

More patterns

ATR trailing stop and a trend exit

The trail compares how far price has come back from the best close with an ATR distance stored above TradeManager (ATR(period=14), then Scale(by=2.5)).

TradeManager(entries='entries', prices='ohlcv'),
{
    'trail':      [{'fast': [GiveBack()], 'slow': [Load('trail_dist')]}, Crossover(), AboveThresholdFilter(threshold=0.0), Exit()],
    'trend_down': [Load('trend_down'), Exit()],
},
Exposure(),
EqualWeightSizer(),

Take half off, then breakeven

At +5% sell half the initial size. A second dict step, below the first, reads SoldFraction() and exits the rest if the trade comes back to its entry.

TradeManager(entries='entries', prices='ohlcv'),
{
    'stop':   [TradeReturn(), BelowThresholdFilter(threshold=-0.05, inclusive=True), Exit()],
    'tp1':    [TradeReturn(), AboveThresholdFilter(threshold=0.05, inclusive=True), Reduce(fraction=0.5)],
    'rsi_63': [Load('rsi_hot'), Exit()],
},
{
    'breakeven': [
        {
            'after_tp1': [SoldFraction(), AboveThresholdFilter(threshold=0.0)],
            'at_entry':  [TradeReturn(), BelowThresholdFilter(threshold=0.0, inclusive=True)],
        },
        MaskAnd(),
        Exit(),
    ],
},
Exposure(),
FixedWeightSizer(weight_per_position=0.2),
LeverageCap(max_leverage=1.0),

Time and stale exits

Exit after 48 bars, or after 10 bars if the trade is still red, or on a 5% trail. BarsHeld() counts from this trade's entry, not from the signal.

TradeManager(entries='entries', prices='ohlcv'),
{
    'max_hold': [BarsHeld(), AboveThresholdFilter(threshold=48, inclusive=True), Exit()],
    'stale':    [
        {
            'old': [BarsHeld(), AboveThresholdFilter(threshold=10, inclusive=True)],
            'red': [TradeReturn(), BelowThresholdFilter(threshold=0.0)],
        },
        MaskAnd(),
        Exit(),
    ],
    'trail':    [DrawdownFromPeak(), BelowThresholdFilter(threshold=-0.05), Exit()],
},
Exposure(),
EqualWeightSizer(),

DCA: safety orders from the last fill

Add a unit every time price is 2% below the last fill, up to 4 adds, and take profit 2% above the average entry. The sizer's weight is per unit: at most 5 × 0.04 = 0.2 per asset.

TradeManager(entries='entries', prices='ohlcv'),
{
    'safety': [ReturnSinceLastFill(), BelowThresholdFilter(threshold=-0.02, inclusive=True), ScaleIn(units=1.0, times=4)],
    'take':   [ReturnFromAverage(), AboveThresholdFilter(threshold=0.02, inclusive=True), Exit()],
    'stop':   [TradeReturn(), BelowThresholdFilter(threshold=-0.15, inclusive=True), Exit()],
},
Exposure(),
FixedWeightSizer(weight_per_position=0.04),
LeverageCap(max_leverage=1.0),

Cooldowns, trades per day and a daily loss limit

reentry='any_bar' lets a signal that is still on re-enter once the cooldown has passed. The daily loss limit is per asset: once that asset's realized loss for the UTC day reaches 3%, no new trade opens on it until the next day.

TradeManager(entries='entries', prices='ohlcv', reentry='any_bar'),
{
    'stop':       [TradeReturn(), BelowThresholdFilter(threshold=-0.03, inclusive=True), Exit()],
    'target':     [TradeReturn(), AboveThresholdFilter(threshold=0.06, inclusive=True), Exit()],
    'cooldown':   [BarsSinceExit(), AboveThresholdFilter(threshold=4, inclusive=True), AllowEntry()],
    'two_a_day':  [EntryCount(window='1d'), BelowThresholdFilter(threshold=2.0), AllowEntry()],
    'daily_loss': [RealizedPnL(window='1d'), AboveThresholdFilter(threshold=-0.03), AllowEntry()],
},
Exposure(),
EqualWeightSizer(),

What is not expressible yet

  • "Max N open positions" across assets (or one position at a time across BTC and ETH). Rules apply per asset; a limit across the whole book is a later portfolio-layer project. Per asset, EntryCount(window=) caps entries.
  • Intrabar or resting fills. Every rule evaluates at bar close and changes the weight; Keel places no resting stop or take-profit orders on the exchange.

Live and backtest

Live and backtest positions. Live recomputes a strategy over its last 365 days at every bar. A position that depends on its own past trades (a stop, a target or a re-entry gate) matches the backtest whenever the strategy has been flat, with its entry off, at some point in that window. If a trade stays open longer than the window, or trades re-enter back to back without a flat bar, live can differ from the backtest until the strategy is next flat. A backtest reports this as LIVE_WINDOW_SHORTER_THAN_TRADES. A market exit (for example Load('regime_off') → Exit()) or a time limit (MaxHold) keeps trades inside the window.