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.
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:
- A reader turns the Position into an ordinary series, for example
TradeReturn()(this trade's return from its entry, as a fraction),BarsHeld()orDrawdownFromPeak(). - Ordinary components work on that series unchanged, for example
BelowThresholdFilter(threshold=-0.05, inclusive=True). - An action applies the result to the trade:
Exit(),Reduce(fraction=),ScaleIn(units=, times=)orAllowEntry().
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 byAtEntry(slot=)for an R multiple.BarsHeld(): bars since this trade's entry (0 on the entry bar).DrawdownFromPeak()(a fraction, at most 0) andGiveBack()(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 readsSoldFraction()orAddCount()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 totimesper 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()]; withfraction=it ends inReduceinstead.TrailingStop(pct=0.08):[DrawdownFromPeak(), BelowThresholdFilter(threshold=-0.08), Exit()].TrailingStop(atr=2.5, period=14)comparesGiveBack()with 2.5 × ATR.MaxHold(bars=48)orMaxHold(window='2d'):[BarsHeld(), AboveThresholdFilter(threshold=48, inclusive=True), Exit()].Cooldown(bars=4)orCooldown(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
ScaleInrules, 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 exampleLoad('regime_off') → Exit()) or a time limit (MaxHold) keeps trades inside the window.
Your First Backtest (Web App)
Walkthrough of building and backtesting a strategy in the Keel web app. CLI-first users should follow Getting Started instead.
Agent Setup
Connect an AI agent to Keel. The hosted MCP endpoint is the default — paste one URL and sign in; the keel-trade package is the other way in.