exitkit
Swap your exit policy the way you swap your entry signal.
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.
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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file exitkit-0.1.0.tar.gz.
File metadata
- Download URL: exitkit-0.1.0.tar.gz
- Upload date:
- Size: 311.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
be5ef0ebcc467fe0c7f8e50d7209ad665bf12f17a8ef36c2567f044929dd4046
|
|
| MD5 |
d83fa4b5d9082ef3974724e9460c630e
|
|
| BLAKE2b-256 |
aedf3f0184b086912b339f72ae41ffb78f3ca3b7c4f46c01e9225ef6903a6890
|
Provenance
The following attestation bundles were made for exitkit-0.1.0.tar.gz:
Publisher:
publish.yml on charlieyanhx/exitkit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
exitkit-0.1.0.tar.gz -
Subject digest:
be5ef0ebcc467fe0c7f8e50d7209ad665bf12f17a8ef36c2567f044929dd4046 - Sigstore transparency entry: 2704486462
- Sigstore integration time:
-
Permalink:
charlieyanhx/exitkit@5132443d57a8f59cf251d0fd54b3d3b6b3e26917 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/charlieyanhx
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@5132443d57a8f59cf251d0fd54b3d3b6b3e26917 -
Trigger Event:
push
-
Statement type:
File details
Details for the file exitkit-0.1.0-py3-none-any.whl.
File metadata
- Download URL: exitkit-0.1.0-py3-none-any.whl
- Upload date:
- Size: 32.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eae45b1d5e8254d502916f6eb11a7ba6c3beacfa28058b8b2819b30ead9ed367
|
|
| MD5 |
38fdae1b1c8e93d165a6eaa0cf35e714
|
|
| BLAKE2b-256 |
2a4aabd6fb2c2bc0e3b82033f6f5240df99be2a3f7fde3eed782d6336f4d0ce4
|
Provenance
The following attestation bundles were made for exitkit-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on charlieyanhx/exitkit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
exitkit-0.1.0-py3-none-any.whl -
Subject digest:
eae45b1d5e8254d502916f6eb11a7ba6c3beacfa28058b8b2819b30ead9ed367 - Sigstore transparency entry: 2704486506
- Sigstore integration time:
-
Permalink:
charlieyanhx/exitkit@5132443d57a8f59cf251d0fd54b3d3b6b3e26917 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/charlieyanhx
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@5132443d57a8f59cf251d0fd54b3d3b6b3e26917 -
Trigger Event:
push
-
Statement type: