Skip to main content

exitkit

Swap your exit policy the way you swap your entry signal.

tests PyPI Python License: MIT

Twenty-seven exit models in six families behind one interface. Entry logic is well served by open-source backtesting libraries; exit logic usually is not — most ship one stop and one target and leave the rest to you.

Same entry signal, five exit policies

One entry rule (10/30 SMA crossover), five exit policies, backtesting.py's sample data. The 30-day time limit holds a 22% drawdown through 2008–09 where the others reach 65%. Regenerate it with examples/plot_exit_policies.py.

pip install exitkit

Thirty seconds

import time
from exitkit import StopLossExitModel, Position, SignalOutput

position = Position(
    position_id="p1",
    entry_time=time.time() - 3600,
    entry_signal=SignalOutput(direction=1, meta={"implied_vol": 0.18}),
    entry_price=400.0,
    quantity=10,
)

model = StopLossExitModel(stop_loss_pct=0.02, trailing=True)

for signal in model.generate_exit_signals(
    [position], {"spot_price": 391.0, "implied_vol": 0.21}
):
    print(signal.exit_reason, signal.meta["loss_pct"], signal.confidence)
stop_loss -0.0225 1.0

Every model takes (positions, market_data) and returns SignalOutput objects carrying the position they close and why. Swapping policy is swapping the constructor.

Works with backtesting.py

pip install exitkit[backtesting]
from backtesting import Backtest, Strategy
from exitkit import StopLossExitModel, FixedTimeExitModel
from exitkit.adapters.backtesting_py import ExitMixin

class SmaCross(ExitMixin, Strategy):
    exit_models = [StopLossExitModel(0.05), FixedTimeExitModel(24 * 30)]

    def next(self):
        self.apply_exits()          # close whatever the policy says to close
        if crossover(self.s1, self.s2) and not self.position:
            self.buy()

Same entry signal, six exit policies, on backtesting.py's own sample data (examples/compare_exit_policies.py):

exit policy           return %  trades  max DD %  Sharpe
none (hold)              326.1       1     -65.3    0.47
stop 2%                   47.6       8     -64.6    0.15
stop 5%                  283.9       2     -65.3    0.44
take profit 10%          187.5      10     -64.0    0.39
time limit 30d           208.3      31     -22.3    0.68
stop 5% + tp 10%         100.0      29     -28.3    0.49

One dataset and one entry rule, so read it as an illustration rather than a finding — but it is the comparison the library exists to make cheap. Holding time is measured against the bar's clock, not the wall clock, so a replay ages positions by simulated time rather than by whenever you happened to run it.

The six families

Family Models
stop_loss fixed, adaptive, volatility-scaled, time-decayed
take_profit fixed, partial, adaptive, scaling, momentum-aware
time_based fixed horizon, time decay, adaptive, market hours, performance-conditioned
volatility breakout, regime, mean-reversion, clustering
signal_reversal reversal, strength decay, divergence, consistency
convergence single-target (three variants) and multi-target
from exitkit import FAMILIES

for name, models in FAMILIES.items():
    print(name, [m.__name__ for m in models])

FAMILIES is also how the test suite exercises every model uniformly — adding a model puts it under the whole battery automatically.

Missing market data raises

The one opinion this library holds. Required fields are checked at the boundary and name what is absent:

model.generate_exit_signals([position], {"implied_vol": 0.21})
MissingMarketData: market data is missing 'spot_price'; got: implied_vol.
Exit models require this field - supply it rather than letting a default stand
in, which silently fabricates the decision.

None, NaN and unparseable values count as missing. 0.0 does not.

Where this fits

exitkit decides when to close. It does not fetch data, route orders, or run a backtest loop — hand it positions and market data from whatever you already use.

If you want Use
A full backtest engine backtesting.py, vectorbt
One trailing stop, built in backtesting.py's TrailingStrategy
Intrabar stop/target fills wickra-backtest
Many exit policies to compare exitkit

SignalOutput is a plain dataclass, so wiring it into an existing engine is a translation layer, not an adoption.

Why this exists

The catalogue was extracted from a private options-research program. Writing the test suite surfaced three defects that had survived in running code, all fixed here with regression tests named after them.

Thirty-six fabricated market-data fallbacks. Every model read its inputs as market_data.get('spot_price', 350.0) or .get('implied_vol', 0.2). A caller who omitted a field did not get an error — they got an exit decision computed against an invented price. The volatility default is quieter still: it appears in ratio denominators, so a missing value produces a vol ratio of exactly 1.0, which reads as "no change" rather than "no data". That is why the boundary check above exists.

Time-based exits could not fire. check_time_exit read position.get_holding_hours(), which divided holding_period — a field only ever assigned inside Position.update_pnl(). A model that did not first mark the position saw zero hours held, so a position held nine hours against a two-hour limit did not exit. Holding time is now derived from entry_time; a derived quantity should not depend on another call's side effect.

MarketHoursExitModel had never run. The module called time.time() without importing time, so every invocation raised NameError. It also defaulted its timestamp to the wall clock, which meant a backtest evaluated market hours against whenever you happened to run it — a run at 02:00 would hold everything. That one was found by CI, which runs in a different timezone than the author's laptop, and is why the suite now pins fixed instants.

The feature-window argument was also removed from the required position in the signature: it was the first parameter of every model and not one of them read it.

Tests

pip install -e ".[test]"
pytest -q

141 tests. Four are parametrized across all twenty-seven models, so each must construct, refuse to decide on empty market data, run on complete data, and return nothing when there are no positions.

Licence

MIT. See CHANGELOG.md and CONTRIBUTING.md.

Metadata

Release files for exitkit 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for exitkit 0.1.0
File Size Uploaded Provenance Actions
exitkit-0.1.0.tar.gz 311.6 kB With provenance Details for exitkit-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for exitkit 0.1.0
File Interpreter ABI Platform Actions
exitkit-0.1.0-py3-none-any.whl Python 3 none any Details for exitkit-0.1.0-py3-none-any.whl

Total release size: 343.9 kB

Release files / exitkit-0.1.0.tar.gz

Download URL exitkit-0.1.0.tar.gz
Size 311.6 kB
Tags Source
SHA-256 checksum
How to use checksums
be5ef0ebcc467fe0c7f8e50d7209ad665bf12f17a8ef36c2567f044929dd4046
BLAKE2b-256 checksum
How to use checksums
aedf3f0184b086912b339f72ae41ffb78f3ca3b7c4f46c01e9225ef6903a6890
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.

Transparency log

Release files / exitkit-0.1.0-py3-none-any.whl

Download URL exitkit-0.1.0-py3-none-any.whl
Size 32.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
eae45b1d5e8254d502916f6eb11a7ba6c3beacfa28058b8b2819b30ead9ed367
BLAKE2b-256 checksum
How to use checksums
2a4aabd6fb2c2bc0e3b82033f6f5240df99be2a3f7fde3eed782d6336f4d0ce4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.

Transparency log

Security for exitkit 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for exitkit-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page